Macros 2.0 (v0.7.1) - Macro Autocomplete everywhere (#5019) * Add macro autocomplete for free text inputs Implements macro autocomplete (`{{`) for textareas and input fields marked with `data-macros` attribute. Extracts shared autocomplete logic into MacroAutoCompleteHelper.js for consistency between slash command and free text contexts. Includes MutationObserver for dynamic element initialization and supports variable shorthands, operators, flags, and scoped macro closing tags. * fix annoying circular dependency again * Add `data-macros` attribute to enable macro autocomplete on text inputs Enables macro autocomplete (`{{`) for various textareas and input fields throughout the UI by adding the `data-macros` attribute. Includes context template fields, instruct mode sequences, character/persona descriptions, world info content, and other prompt-related inputs. Also ensures the attribute is preserved when expanding editors and creating world info entries. * Allow macro autocomplete in editing messages Enables macro autocomplete (`{{`) when editing messages by adding the `data-macros` attribute to the dynamically created edit textarea element. * Add autocomplete visibility setting support for macro autocomplete Respects the global STScript autocomplete visibility setting (`power_user.stscript.autocomplete.state`) for macro autocomplete in free text inputs. When set to "Input length > 1", macro autocomplete only activates after typing `{{` plus at least 2 characters. Also adds a tooltip to the visibility setting explaining it applies to both slash commands and macros. * Fix unclosed div tag in STScript autocomplete visibility setting Closes the `<div>` tag that was incorrectly left open in the autocomplete visibility setting markup. * Add setting to control macro autocomplete visibility in non-expanded fields Introduces a new `showInAllMacroFields` setting that controls whether macro autocomplete appears in all macro-enabled fields or only in expanded editors and when pressing Ctrl+Space. Also adds `data-macros-autocomplete` attribute support with `always` and `hide` modes for per-field override. When the setting is off, autocomplete only shows in expanded editors (which now get `data-macros-autocomplete="always"`) and the completion * Add `data-macros-autocomplete-style` attribute to control macro autocomplete popup size Introduces a new `data-macros-autocomplete-style` attribute with `small` (33vw, max 700px) and `expanded` (default chat width) modes to control macro autocomplete popup dimensions. The `small` style is now the default for inline fields, while `expanded` is used for expanded editors and prompt manager. Also refactors `getAutocompleteMode` to return `DEFAULT` instead of `null` and adds corresponding `getAutocompleteStyle` helper * Fix autocomplete details panel alignment when list is constrained by viewport edge Adjusts the details panel position to align with the actual autocomplete list position when the list is constrained by the right edge of the viewport. Previously, the panel would position based on cursor location even when the list was pushed left, causing misalignment. Now checks if the list's actual position differs from the calculated position (with 5px tolerance) and uses the list's position instead. * Guard autocomplete details panel alignment check with `isReplaceable` condition Prevents attempting to read the autocomplete list's bounding rect when the list is not visible. The alignment adjustment for viewport-constrained lists now only runs when `isReplaceable` is true, avoiding potential errors when the list DOM is not rendered. * Fix macro autocomplete cursor detection to handle nested macros correctly Updates `findMacroAtCursor` to track nesting depth when searching for opening `{{` and closing `}}` braces. Previously would incorrectly stop at the first brace pair encountered in either direction, breaking autocomplete when cursor was inside nested macros like `{{getvar::{{getvar::name}}}}`. Now properly skips over nested macro boundaries by incrementing/decrementing depth counters until finding the matching braces at depth * gief me my comments back (and fixes, that were missed) - Added detailed JSDoc comments to all exported functions explaining parameters, return types, and behavior - Added inline comments throughout functions explaining logic flow, edge cases, and implementation details - Documented parser-based vs regex-based unclosed scope detection approaches - Clarified variable shorthand autocomplete logic including operator filtering, context display, and priority * refactor(macros): freeze MACRO_AUTOCOMPLETE_MODE and MACRO_AUTOCOMPLETE_STYLE enums * feat(macros): explicitly set `makeSelectable` flag for non-insertable autocomplete options - Set `makeSelectable = true` for variable shorthand options in {{if}} conditions (already insertable) - Set `makeSelectable = false` for already-typed operators in variable shorthand autocomplete - Set `makeSelectable = false` for already-typed flags in macro autocomplete - Set `makeSelectable = false` for non-selectable closing block flags when no unclosed scopes exist - Set `makeSelectable = false` for sc * refactor(macros): consolidate typedef imports in MacroAutoCompleteHelper - Fixed typo in MacroDefinition typedef import (removed extra `/` from JSDoc comment) - Replaced long-form typedef imports with short-form aliases for MacroAutoCompleteContext and MacroDefinition - Improved code consistency by using imported typedefs throughout function signatures * refactor(autocomplete): select first selectable item as default instead of always first item - Extracted default item selection logic into `selectDefaultItem()` method - Changed default selection to prioritize first selectable item over first item in list - Falls back to last item when no selectable items exist (preserves context for info-only options) - Added JSDoc documentation explaining selection behavior * feat(macros): make already-typed variable names non-selectable in autocomplete - Set `makeSelectable = false` for variable name options that match the currently typed name - Set empty `valueProvider` for matched variables to prevent re-insertion - Prevents redundant selection of variables that are already fully typed in the input * feat(macros): add value context autocomplete option for variable shorthand operators - Created `VariableValueContextAutoCompleteOption` class to display context about expected values - Shows operator name, symbol, and description when typing variable shorthand values - Displays currently typed value and completion hint - Non-selectable option (context only) with priority 4 - Added to variable shorthand autocomplete when operator is typed and value is being entered * fix lint --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

9ff9d59672e639d209a4d9804ee5584f3424802b

Wolfsblvt <wolfsblvt@gmail.com>

Signed
11 files changed, +1513 -834Showing whitespace changes
public/index.html+44 -36
@@ -4006,7 +4006,7 @@
40064006 <small data-i18n="Story String">Story String</small>
40074007 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="context_story_string" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
40084008 </label>
40094009 <textarea id="context_story_string" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
40104010 </div>
40114011 <div class="flex-container flexFlowColumn" data-cc-null>
40124012 <div id="context_story_string_position_block">
@@ -4043,7 +4043,7 @@
40434043 <small data-i18n="Example Separator">Example Separator</small>
40444044 </label>
40454045 <div>
40464046 <textarea id="context_example_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
40474047 </div>
40484048 </div>
40494049 <div class="flex1">
@@ -4051,7 +4051,7 @@
40514051 <small data-i18n="Chat Start">Chat Start</small>
40524052 </label>
40534053 <div>
40544054 <textarea id="context_chat_start" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
40554055 </div>
40564056 </div>
40574057 </div>
@@ -4215,11 +4215,11 @@
42154215 <div class="flex-container">
42164216 <div class="flexAuto" title="Inserted before a User message and as a last prompt line when impersonating." data-i18n="[title]Inserted before a User message and as a last prompt line when impersonating.">
42174217 <small data-i18n="User Prefix">User Message Prefix</small>
42184218 <textarea id="instruct_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42194219 </div>
42204220 <div class="flexAuto" title="Inserted after a User message." data-i18n="[title]Inserted after a User message.">
42214221 <small data-i18n="User Suffix">User Message Suffix</small>
42224222 <textarea id="instruct_input_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42234223 </div>
42244224 </div>
42254225 </details>
@@ -4228,11 +4228,11 @@
42284228 <div class="flex-container">
42294229 <div class="flexAuto" title="Inserted before an Assistant message and as a last prompt line when generating an AI reply." data-i18n="[title]Inserted before an Assistant message and as a last prompt line when generating an AI reply.">
42304230 <small data-i18n="Assistant Prefix">Assistant Message Prefix</small>
42314231 <textarea id="instruct_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42324232 </div>
42334233 <div class="flexAuto" title="Inserted after an Assistant message." data-i18n="[title]Inserted after an Assistant message.">
42344234 <small data-i18n="Assistant Suffix">Assistant Message Suffix</small>
42354235 <textarea id="instruct_output_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42364236 </div>
42374237 </div>
42384238 </details>
@@ -4241,11 +4241,11 @@
42414241 <div class="flex-container">
42424242 <div class="flexAuto" id="instruct_system_sequence_block" title="Inserted before a System (added by slash commands or extensions) message." data-i18n="[title]Inserted before a System (added by slash commands or extensions) message.">
42434243 <small data-i18n="System Prefix">System Message Prefix</small>
42444244 <textarea id="instruct_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42454245 </div>
42464246 <div class="flexAuto" id="instruct_system_suffix_block" title="Inserted after a System message." data-i18n="[title]Inserted after a System message.">
42474247 <small data-i18n="System Suffix">System Message Suffix</small>
42484248 <textarea id="instruct_system_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42494249 </div>
42504250 </div>
42514251 <div class="flexBasis100p" title="If enabled, System Sequences will be the same as User Sequences." data-i18n="[title]If enabled, System Sequences will be the same as User Sequences.">
@@ -4260,37 +4260,37 @@
42604260 <div class="flex-container">
42614261 <div class="flexAuto" title="Inserted before the first Assistant's message." data-i18n="[title]Inserted before the first Assistant's message.">
42624262 <small data-i18n="First Assistant Prefix">First Assistant Prefix</small>
42634263 <textarea id="instruct_first_output_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42644264 </div>
42654265 <div class="flexAuto" title="Inserted before the last Assistant's message or as a last prompt line when generating an AI reply (except a neutral/system role)." data-i18n="[title]instruct_last_output_sequence">
42664266 <small data-i18n="Last Assistant Prefix">Last Assistant Prefix</small>
42674267 <textarea id="instruct_last_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42684268 </div>
42694269 </div>
42704270 <div class="flex-container">
42714271 <div class="flexAuto" title="Inserted before the first User's message." data-i18n="[title]Inserted before the first User's message.">
42724272 <small data-i18n="First User Prefix">First User Prefix</small>
42734273 <textarea id="instruct_first_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42744274 </div>
42754275 <div class="flexAuto" title="Inserted before the last User's message." data-i18n="[title]instruct_last_input_sequence">
42764276 <small data-i18n="Last User Prefix">Last User Prefix</small>
42774277 <textarea id="instruct_last_input_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
42784278 </div>
42794279 </div>
42804280 <div class="flex-container">
42814281 <div class="flexAuto" title="Will be inserted as a last prompt line when using system/neutral generation." data-i18n="[title]Will be inserted as a last prompt line when using system/neutral generation.">
42824282 <small data-i18n="System Instruction Prefix">System Instruction Prefix</small>
42834283 <textarea id="instruct_last_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42844284 </div>
42854285 <div class="flexAuto" title="If a stop sequence is generated, everything past it will be removed from the output (inclusive)." data-i18n="[title]If a stop sequence is generated, everything past it will be removed from the output (inclusive).">
42864286 <small data-i18n="Stop Sequence">Stop Sequence</small>
42874287 <textarea id="instruct_stop_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42884288 </div>
42894289 </div>
42904290 <div class="flex-container">
42914291 <div class="flexAuto" title="Will be inserted at the start of the chat history if it doesn't start with a User message." data-i18n="[title]Will be inserted at the start of the chat history if it doesn't start with a User message.">
42924292 <small data-i18n="User Filler Message">User Filler Message</small>
42934293 <textarea id="instruct_user_alignment_message" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
42944294 </div>
42954295 </div>
42964296 </details>
@@ -4328,7 +4328,7 @@
43284328 <small data-i18n="Prompt Content">Prompt Content</small>
43294329 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_content" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
43304330 </label>
43314331 <textarea id="sysprompt_content" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
43324332 </div>
43334333
43344334 <div>
@@ -4336,7 +4336,7 @@
43364336 <small data-i18n="Post-History Instructions">Post-History Instructions</small>
43374337 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="sysprompt_post_history" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
43384338 </label>
43394339 <textarea id="sysprompt_post_history" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
43404340 </div>
43414341 </div>
43424342
@@ -4461,17 +4461,17 @@
44614461 <div class="flex-container">
44624462 <div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix">
44634463 <small data-i18n="Prefix">Prefix</small>
44644464 <textarea id="reasoning_prefix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44654465 </div>
44664466 <div class="flex1" title="Inserted after the reasoning content." data-i18n="[title]reasoning_suffix">
44674467 <small data-i18n="Suffix">Suffix</small>
44684468 <textarea id="reasoning_suffix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44694469 </div>
44704470 </div>
44714471 <div class="flex-container">
44724472 <div class="flex1" title="Inserted between the reasoning and the message content." data-i18n="[title]reasoning_separator">
44734473 <small data-i18n="Separator">Separator</small>
44744474 <textarea id="reasoning_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
44754475 </div>
44764476 </div>
44774477 </details>
@@ -4493,7 +4493,7 @@
44934493 </span>
44944494 </small>
44954495 <div>
44964496 <input id="markdown_escape_strings" data-macros class="text_pole textarea_compact" type="text" data-i18n="[placeholder]comma delimited,no spaces between" placeholder="comma delimited,no spaces between" />
44974497 </div>
44984498 </div>
44994499
@@ -4505,7 +4505,7 @@
45054505 </span>
45064506 </small>
45074507 <div>
45084508 <textarea id="start_reply_with" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
45094509 </div>
45104510 <label class="checkbox_label" for="chat-show-reply-prefix-checkbox">
45114511 <input id="chat-show-reply-prefix-checkbox" type="checkbox" />
@@ -5386,6 +5386,7 @@
53865386 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
53875387 </div>
53885388 <div class="inline-drawer-content">
5389+ <div class="flex1" title="When to show the autocomplete for slash commands and macros." data-i18n="[title]When to show the autocomplete for slash commands and macros.">
53895390 <label for="stscript_autocomplete_state">
53905391 <small data-i18n="Visibility">Visibility</small>
53915392 </label>
@@ -5394,12 +5395,19 @@
53945395 <option value="1" data-i18n="Input length > 1">Input length > 1</option>
53955396 <option value="2" data-i18n="Always show">Always show</option>
53965397 </select>
5398+ </div>
53975399 <label class="checkbox_label" for="stscript_autocomplete_autoHide">
53985400 <input id="stscript_autocomplete_autoHide" type="checkbox" />
53995401 <small data-i18n="Automatically hide details">
54005402 Automatically hide details
54015403 </small>
54025404 </label>
5405+ <label class="checkbox_label" for="stscript_autocomplete_showInAllMacroFields" title="Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space." data-i18n="[title]Show macro autocomplete in all macro-enabled fields. When off, autocomplete only shows in expanded editors or when pressing Ctrl+Space.">
5406+ <input id="stscript_autocomplete_showInAllMacroFields" type="checkbox" />
5407+ <small data-i18n="Show in all macro fields">
5408+ Show in all macro fields
5409+ </small>
5410+ </label>
54035411 <div class="flex-container">
54045412 <div class="flex1" title="Determines how entries are found for autocomplete." data-i18n="[title]Determines how entries are found for autocomplete.">
54055413 <label for="stscript_matching">
@@ -5719,7 +5727,7 @@
57195727 <span data-i18n="Persona Description">Persona Description</span>
57205728 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="persona_description" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
57215729 </h4>
57225730 <textarea id="persona_description" name="persona_description" data-macros data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example:&#10;[{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea>
57235731
57245732 <div class="flex-container justifySpaceBetween">
57255733 <h4 data-i18n="Position">Position</h4>
@@ -5978,7 +5986,7 @@
59785986 </span>
59795987 </div>
59805988 </div>
59815989 <textarea id="description_textarea" class="mdHotkeys" data-macros data-i18n="[placeholder]Describe your character's physical and mental traits here." placeholder="Describe your character's physical and mental traits here." name="description" placeholder=""></textarea>
59825990 <div class="extension_token_counter">
59835991 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span>
59845992 </div>
@@ -5998,7 +6006,7 @@
59986006 </span>
59996007 </div>
60006008 </div>
60016009 <textarea classid="mdHotkeysfirstmessage_textarea" idclass="firstmessage_textareamdHotkeys" data-macros data-i18n="[placeholder]This will be the first message from the character that starts every chat." placeholder="This will be the first message from the character that starts every chat." name="first_mes" placeholder=""></textarea>
60026010 <div class="extension_token_counter">
60036011 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span>
60046012 </div>
@@ -6322,7 +6330,7 @@
63226330 <span data-i18n="Main Prompt">Main Prompt</span>
63236331 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="system_prompt_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
63246332 </h4>
63256333 <textarea id="system_prompt_textarea" name="system_prompt" data-macros data-i18n="[placeholder]Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)" placeholder="Any contents here will replace the default Main Prompt used for this character.&#10;(v2 spec: system_prompt)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
63266334 <div class="extension_token_counter">
63276335 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="system_prompt_textarea">counting...</span>
63286336 </div>
@@ -6332,7 +6340,7 @@
63326340 <span data-i18n="Post-History Instructions">Post-History Instructions</span>
63336341 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="post_history_instructions_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
63346342 </h4>
63356343 <textarea id="post_history_instructions_textarea" name="post_history_instructions" data-macros data-i18n="[placeholder]Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)" placeholder="Any contents here will replace the default Post-History Instructions used for this character.&#10;(v2 spec: post_history_instructions)" form="form_create" class="text_pole" autocomplete="off" rows="3"></textarea>
63366344 <div class="extension_token_counter">
63376345 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="post_history_instructions_textarea">counting...</span>
63386346 </div>
@@ -6385,7 +6393,7 @@
63856393 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="personality_textarea" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
63866394 <a href="https://docs.sillytavern.app/usage/core-concepts/characterdesign/#personality-summary" class="notes-link" target="_blank"><span class="fa-solid fa-circle-question note-link-span"></span></a>
63876395 </h4>
63886396 <textarea id="personality_textarea" name="personality" data-macros data-i18n="[placeholder](A brief description of the personality)" placeholder="(A brief description of the personality)" form="form_create" class="text_pole" autocomplete="off" rows="4"></textarea>
63896397 <div class="extension_token_counter">
63906398 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="personality_textarea" data-token-permanent="true">counting...</span>
63916399 </div>
@@ -6398,7 +6406,7 @@
63986406 <span class="fa-solid fa-circle-question note-link-span"></span>
63996407 </a>
64006408 </h4>
64016409 <textarea id="scenario_pole" name="scenario" data-macros data-i18n="[placeholder](Circumstances and context of the interaction)" placeholder="(Circumstances and context of the interaction)" class="text_pole" value="" autocomplete="off" form="form_create" rows="4"></textarea>
64026410 <div class="extension_token_counter">
64036411 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="scenario_pole" data-token-permanent="true">counting...</span>
64046412 </div>
@@ -6411,7 +6419,7 @@
64116419 </span>
64126420 <i class="editor_maximize fa-solid fa-maximize right_menu_button" data-for="depth_prompt_prompt" title="Expand the editor" data-i18n="[title]Expand the editor"></i>
64136421 </h4>
64146422 <textarea id="depth_prompt_prompt" name="depth_prompt_prompt" data-macros class="text_pole" rows="5" autocomplete="off" form="form_create" data-i18n="[placeholder](Text to be inserted in-chat @ designated depth and role)" placeholder="(Text to be inserted in-chat @ designated depth and role)"></textarea>
64156423 </div>
64166424 <div>
64176425 <h4>
@@ -6461,7 +6469,7 @@
64616469 </a>
64626470 </h5>
64636471 </div>
64646472 <textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" data-macros data-i18n="[placeholder](Examples of chat dialog. Begin each example with START on a new line.)" placeholder="(Examples of chat dialog. Begin each example with &lt;START&gt; on a new line.)" form="form_create" rows="6"></textarea>
64656473 <div class="extension_token_counter">
64666474 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span>
64676475 </div>
@@ -7154,7 +7162,7 @@
71547162 <span>&nbsp;</span>
71557163 <span id="completion_prompt_manager_popup_entry_source"></span>
71567164 </div>
71577165 <textarea id="completion_prompt_manager_popup_entry_form_prompt" class="text_pole" name="prompt" data-macros data-macros-autocomplete="always" data-macros-autocomplete-style="expanded" placeholder="The prompt to be sent." data-i18n="[placeholder]The prompt to be sent."></textarea>
71587166 </div>
71597167 <div class="completion_prompt_manager_popup_entry_form_footer">
71607168 <a id="completion_prompt_manager_popup_entry_form_close" title="Close" data-i18n="[title]close" class="fa-solid fa-close menu_button"></a>
@@ -7530,7 +7538,7 @@
75307538 <b data-i18n="Unique to this chat">Unique to this chat</b>.<br>
75317539 <span data-i18n="Checkpoints inherit the Note from their parent, and can be changed individually after that.">Checkpoints inherit the Note from their parent, and can be changed individually after that.</span><br>
75327540 </small>
75337541 <textarea id="extension_floating_prompt" data-macros class="text_pole textarea_compact" rows="8"></textarea>
75347542 <div class="extension_token_counter">
75357543 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_prompt_token_counter">0</span>
75367544 </div>
@@ -7587,7 +7595,7 @@
75877595 <div class="inline-drawer-content">
75887596 <small data-i18n="Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.">Will be automatically added as the author's note for this character. Will be used in groups, but
75897597 can't be modified when a group chat is open.</small>
75907598 <textarea id="extension_floating_chara" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
75917599 <div class="extension_token_counter">
75927600 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_chara_token_counter">0</span>
75937601 </div>
@@ -7619,7 +7627,7 @@
76197627 </div>
76207628 <div class="inline-drawer-content">
76217629 <small data-i18n="Will be automatically added as the Author's Note for all new chats.">Will be automatically added as the Author's Note for all new chats.</small>
76227630 <textarea id="extension_floating_default" data-macros class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>
76237631 <div class="extension_token_counter">
76247632 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_default_token_counter">0</span>
76257633 </div>
public/script.js+3 -0
@@ -189,6 +189,7 @@ import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids,
189189
190190import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
191191import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
192+import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js';
192193import {
193194 tag_map,
194195 tags,
@@ -711,6 +712,7 @@ async function firstLoadInit() {
711712 initAuthorsNote();
712713 await initPersonas();
713714 await initSlashCommandAutoComplete();
715+ initMacroAutoComplete();
714716 initWorldInfo();
715717 initHorde();
716718 initRossMods();
@@ -8028,6 +8030,7 @@ export async function messageEdit(editMessageId) {
80288030 const editTextArea = document.createElement('textarea');
80298031 editTextArea.id = 'curEditTextarea';
80308032 editTextArea.className = 'edit_textarea mdHotkeys';
8033+ editTextArea.dataset.macros = '';
80318034 messageText.append(editTextArea);
80328035
80338036 const text = trimSpaces(editMessage.mes || '');
public/scripts/autocomplete/AutoComplete.js+36 -2
@@ -275,6 +275,7 @@ export class AutoComplete {
275275 //TODO check if isInput and isForced are both required
276276 this.text = this.textarea.value;
277277 this.isReplaceable = false;
278+ this.isShowForced = isForced; // Store forced state for checkIfActivate to access
278279
279280 if (document.activeElement != this.textarea) {
280281 // only show with textarea in focus
@@ -442,7 +443,7 @@ export class AutoComplete {
442443 } else if (!this.isReplaceable && this.result.length > 1) {
443444 return this.hide();
444445 }
445446 this.selectedItem = this.selectDefaultItem(this.result[0]);
446447 this.isActive = true;
447448 this.wasForced = isForced;
448449 this.renderDebounced();
@@ -600,7 +601,22 @@ export class AutoComplete {
600601 if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
601602 return this.hide();
602603 }
603604 constlet left = Math.max(rect.left, location.left) - layerRect.left;
605+
606+ // Check if the autocomplete list is constrained by the right edge of the viewport.
607+ // If so, adjust the details panel position to align with the actual list position.
608+ // Only do this when the list is actually visible (isReplaceable).
609+ if (this.isReplaceable) {
610+ const listRect = this.dom.getBoundingClientRect();
611+ const listActualLeft = listRect.left - layerRect.left;
612+ const isConstrainedRight = listActualLeft < left - 5; // 5px tolerance
613+
614+ if (isConstrainedRight) {
615+ // Use the actual list position instead of cursor position
616+ left = listActualLeft;
617+ }
618+ }
619+
604620 this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
605621 if (this.isReplaceable) {
606622 this.detailsWrap.classList.remove('full');
@@ -711,6 +727,24 @@ export class AutoComplete {
711727
712728
713729 /**
730+ * Select the default item for the autocomplete list.
731+ * Selects the first selectable item if any is present, or falls back to the last item.
732+ * (To preserve context of where we are with multiple non-selectable options, if they are present for info)
733+ * @param {AutoCompleteOption[]} result The list of autocomplete options.
734+ * @returns {AutoCompleteOption} The item to select.
735+ */
736+ selectDefaultItem(result) {
737+ if (result.length === 0) return null;
738+
739+ // Find first selectable item
740+ const firstSelectable = result.find(it => it.isSelectable);
741+ if (firstSelectable) return firstSelectable;
742+
743+ // Fall back to last item
744+ return result[result.length - 1];
745+ }
746+
747+ /**
714748 * Mark the item at newIdx in the autocomplete list as selected.
715749 * @param {number} newIdx
716750 */
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+83 -0
@@ -1015,6 +1015,89 @@ export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
10151015}
10161016
10171017/**
1018+ * Non-selectable autocomplete option that shows context about the value being typed.
1019+ * Displays info about what value is expected based on the operator.
1020+ */
1021+export class VariableValueContextAutoCompleteOption extends AutoCompleteOption {
1022+ /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1023+ #operatorDef;
1024+
1025+ /** @type {string} */
1026+ #currentValue;
1027+
1028+ /**
1029+ * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1030+ * @param {string} [currentValue=''] - The value currently being typed.
1031+ */
1032+ constructor(operatorDef, currentValue = '') {
1033+ super('value', '📝');
1034+ this.#operatorDef = operatorDef;
1035+ this.#currentValue = currentValue;
1036+ }
1037+
1038+ /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1039+ get operatorDefinition() {
1040+ return this.#operatorDef;
1041+ }
1042+
1043+ /**
1044+ * Renders the autocomplete list item for this value context.
1045+ * @returns {HTMLElement}
1046+ */
1047+ renderItem() {
1048+ const li = this.makeItem(
1049+ '<value>',
1050+ '📝',
1051+ true, // noSlash
1052+ [], // namedArguments
1053+ [], // unnamedArguments
1054+ 'any', // returnType
1055+ `${this.#operatorDef.name} (${this.#operatorDef.symbol}) expects a value`,
1056+ );
1057+ li.setAttribute('data-name', this.name);
1058+ li.setAttribute('data-option-type', 'variable-value-context');
1059+ return li;
1060+ }
1061+
1062+ /**
1063+ * Renders the details panel for this value context.
1064+ * @returns {DocumentFragment}
1065+ */
1066+ renderDetails() {
1067+ const frag = document.createDocumentFragment();
1068+
1069+ const details = document.createElement('div');
1070+ details.classList.add('macro-variable-value-context-details');
1071+
1072+ // Header
1073+ const header = document.createElement('h3');
1074+ header.innerHTML = `Value for <code>${this.#operatorDef.symbol}</code> (${this.#operatorDef.name})`;
1075+ details.append(header);
1076+
1077+ // Description of what value is expected
1078+ const desc = document.createElement('p');
1079+ desc.textContent = this.#operatorDef.description;
1080+ details.append(desc);
1081+
1082+ // Current value being typed
1083+ if (this.#currentValue) {
1084+ const currentNote = document.createElement('p');
1085+ currentNote.innerHTML = `<em>Currently typing:</em> <code>${this.#currentValue}</code>`;
1086+ details.append(currentNote);
1087+ }
1088+
1089+ // Hint
1090+ const hint = document.createElement('p');
1091+ hint.classList.add('hint');
1092+ hint.innerHTML = '<em>Type your value and close with <code>}}</code> to complete the macro.</em>';
1093+ details.append(hint);
1094+
1095+ frag.append(details);
1096+ return frag;
1097+ }
1098+}
1099+
1100+/**
10181101 * Autocomplete option for closing a scoped macro.
10191102 * Suggests {{/macroName}} to close an unclosed scoped macro.
10201103 */
public/scripts/autocomplete/MacroAutoComplete.js+307 -0
@@ -0,0 +1,307 @@
1+/**
2+ * Macro autocomplete for free text inputs (textareas and input fields).
3+ * Provides macro autocomplete when typing `{{` in marked text inputs.
4+ *
5+ * This module uses shared utilities from MacroAutoCompleteHelper.js to ensure
6+ * consistent behavior with the slash command macro autocomplete.
7+ *
8+ * Usage:
9+ * - Mark a textarea/input with `data-macros` or `data-macros="true"` attribute
10+ * - Call `initMacroAutoComplete()` to initialize all marked elements
11+ * - Dynamically added elements are automatically initialized via MutationObserver
12+ */
13+
14+import { power_user } from '../power-user.js';
15+import { AutoComplete, AUTOCOMPLETE_STATE } from './AutoComplete.js';
16+import { findMacroAtCursor, findUnclosedScopes, getMacroAutoCompleteAt } from './MacroAutoCompleteHelper.js';
17+
18+/** Custom attribute name used to mark elements that support macro autocomplete */
19+export const MACRO_AUTOCOMPLETE_ATTRIBUTE = 'data-macros';
20+
21+/** Attribute to control autocomplete visibility: 'always' (force show) or 'hide' (never show) */
22+export const MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE = 'data-macros-autocomplete';
23+
24+/** Generic attribute to control autocomplete popup style/size (used by AutoComplete) */
25+export const MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE = 'data-macros-autocomplete-style';
26+
27+/**
28+ * @readonly
29+ * @enum {string}
30+ */
31+export const MACRO_AUTOCOMPLETE_MODE = Object.freeze({
32+ /** Default behavior: respects global setting showInAllMacroFields */
33+ DEFAULT: 'default',
34+ /** Always show autocomplete in this field (expanded editors, prompt manager) */
35+ ALWAYS: 'always',
36+ /** Never show autocomplete in this field */
37+ HIDE: 'hide',
38+});
39+
40+/**
41+ * @readonly
42+ * @enum {string}
43+ */
44+export const MACRO_AUTOCOMPLETE_STYLE = Object.freeze({
45+ /** Small popup (33vw, max 700px) for inline fields */
46+ SMALL: 'small',
47+ /** Expanded popup (default chat width) for expanded editors */
48+ EXPANDED: 'expanded',
49+});
50+
51+/** @type {WeakSet<HTMLElement>} Track initialized elements to avoid double-init */
52+const initializedElements = new WeakSet();
53+
54+/** @type {WeakMap<HTMLElement, AutoComplete>} Map elements to their autocomplete instances */
55+const elementAutoCompleteMap = new WeakMap();
56+
57+/**
58+ * Checks if the cursor is positioned where macro autocomplete should activate.
59+ * Activates when:
60+ * - Cursor is right after typing `{{`
61+ * - Cursor is inside a macro `{{...}}`
62+ * - Cursor is in scoped content of an unclosed scoped macro (e.g., after `{{setvar myvar}}`)
63+ *
64+ * @param {string} text - The full text content.
65+ * @param {number} cursorPos - The cursor position.
66+ * @param {Object} [options={}] - Additional options.
67+ * @param {boolean} [options.isForced=false] - Whether this is a forced activation (e.g., Ctrl+Space).
68+ * @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
69+ * @returns {boolean}
70+ */
71+function shouldActivateMacroAutocomplete(text, cursorPos, { isForced = false, autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT } = {}) {
72+ // If mode is 'hide', never show autocomplete
73+ if (autocompleteMode === MACRO_AUTOCOMPLETE_MODE.HIDE) {
74+ return false;
75+ }
76+
77+ // Check if autocomplete is enabled at all
78+ if (power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.DISABLED) {
79+ return false;
80+ }
81+
82+ // Determine if we should show normally based on mode and settings
83+ // ALWAYS mode: always show, DEFAULT mode: respect global setting
84+ const alwaysShow = autocompleteMode === MACRO_AUTOCOMPLETE_MODE.ALWAYS;
85+ const shouldShowNormally = isForced || alwaysShow || power_user.stscript.autocomplete.showInAllMacroFields;
86+
87+ // Whether setting says autocomplete should only activate after typing {{ and two characters after that
88+ // Ctrl+Space (isForced) overrides this restriction
89+ const onlyAfter2 = !isForced && power_user.stscript.autocomplete.state === AUTOCOMPLETE_STATE.MIN_LENGTH;
90+
91+ // Check if we're right after {{ (just typed the second brace)
92+ if (cursorPos >= 2 && text.slice(cursorPos - 2, cursorPos) === '{{') {
93+ return shouldShowNormally && !onlyAfter2;
94+ }
95+
96+ // Check if we're inside a macro
97+ const macro = findMacroAtCursor(text, cursorPos);
98+ if (macro !== null) {
99+ if (!shouldShowNormally) return false;
100+ return !onlyAfter2 || (macro.content.trim()).length >= 2;
101+ }
102+
103+ // Check if we're in scoped content of an unclosed scoped macro
104+ const textUpToCursor = text.slice(0, cursorPos);
105+ const unclosedScopes = findUnclosedScopes(textUpToCursor);
106+ return shouldShowNormally && unclosedScopes.length > 0;
107+}
108+
109+/**
110+ * Sets up macro autocomplete for a text input element.
111+ * The autocomplete will trigger when typing `{{` inside the element.
112+ *
113+ * @param {HTMLTextAreaElement|HTMLInputElement} textarea - The input element.
114+ * @param {Object} [options={}] - Options for the autocomplete.
115+ * @param {MACRO_AUTOCOMPLETE_MODE} [options.autocompleteMode=MACRO_AUTOCOMPLETE_MODE.DEFAULT] - The autocomplete mode.
116+ * @param {MACRO_AUTOCOMPLETE_STYLE} [options.autocompleteStyle=MACRO_AUTOCOMPLETE_STYLE.SMALL] - The autocomplete style.
117+ * @returns {AutoComplete} The autocomplete instance.
118+ */
119+export function setMacroAutoComplete(textarea, { autocompleteMode = MACRO_AUTOCOMPLETE_MODE.DEFAULT, autocompleteStyle = MACRO_AUTOCOMPLETE_STYLE.SMALL } = {}) {
120+ const ac = new AutoComplete(
121+ textarea,
122+ () => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }),
123+ (text, index) => getMacroAutoCompleteAt(text, index),
124+ true, // isFloating - always use floating mode for free text macro autocomplete
125+ );
126+
127+ // Set the style via data attribute for CSS targeting
128+ ac.domWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
129+ ac.detailsWrap.dataset.macrosAutocompleteStyle = autocompleteStyle;
130+
131+ elementAutoCompleteMap.set(textarea, ac);
132+ return ac;
133+}
134+
135+/**
136+ * Gets the autocomplete mode from an element's data-macros-autocomplete attribute.
137+ *
138+ * @param {Element} element - The element to check.
139+ * @returns {MACRO_AUTOCOMPLETE_MODE} The mode ('default', 'always', 'hide').
140+ */
141+function getAutocompleteMode(element) {
142+ if (!element.hasAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE)) {
143+ return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
144+ }
145+ const value = element.getAttribute(MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE);
146+ if (value === MACRO_AUTOCOMPLETE_MODE.ALWAYS || value === MACRO_AUTOCOMPLETE_MODE.HIDE) {
147+ return value;
148+ }
149+ return MACRO_AUTOCOMPLETE_MODE.DEFAULT;
150+}
151+
152+/**
153+ * Gets the autocomplete style from an element's data-autocomplete-style attribute.
154+ *
155+ * @param {Element} element - The element to check.
156+ * @returns {MACRO_AUTOCOMPLETE_STYLE} The style ('expanded', 'small').
157+ */
158+function getAutocompleteStyle(element) {
159+ if (!element.hasAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE)) {
160+ return MACRO_AUTOCOMPLETE_STYLE.SMALL; // Default for macro autocomplete is small
161+ }
162+ const value = element.getAttribute(MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE);
163+ if (value === MACRO_AUTOCOMPLETE_STYLE.SMALL || value === MACRO_AUTOCOMPLETE_STYLE.EXPANDED) {
164+ return value;
165+ }
166+ return MACRO_AUTOCOMPLETE_STYLE.EXPANDED;
167+}
168+
169+/**
170+ * Initializes macro autocomplete on a single element if not already initialized.
171+ *
172+ * @param {HTMLTextAreaElement|HTMLInputElement} element - The element to initialize.
173+ * @returns {AutoComplete|null} The autocomplete instance, or null if already initialized.
174+ */
175+function initializeElement(element) {
176+ if (initializedElements.has(element)) {
177+ return null;
178+ }
179+
180+ if (!(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
181+ return null;
182+ }
183+
184+ const autocompleteMode = getAutocompleteMode(element);
185+ const autocompleteStyle = getAutocompleteStyle(element);
186+ initializedElements.add(element);
187+ return setMacroAutoComplete(element, { autocompleteMode, autocompleteStyle });
188+}
189+
190+/**
191+ * Checks if an element has the macro autocomplete attribute enabled.
192+ * Supports both `data-macros` (presence) and `data-macros="true"`.
193+ *
194+ * @param {Element} element - The element to check.
195+ * @returns {boolean}
196+ */
197+function hasMacroAttribute(element) {
198+ if (!element.hasAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE)) {
199+ return false;
200+ }
201+ const value = element.getAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE);
202+ // Attribute present with no value, empty string, or "true" all count as enabled
203+ return value === null || value === '' || value === 'true';
204+}
205+
206+/**
207+ * Handles node changes from MutationObserver - checks for macro autocomplete attribute.
208+ *
209+ * @param {Node} node - The node to check.
210+ */
211+function handleNodeChange(node) {
212+ if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) {
213+ return;
214+ }
215+
216+ // Check if this element has the macro autocomplete attribute
217+ if (hasMacroAttribute(node)) {
218+ if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
219+ initializeElement(node);
220+ }
221+ }
222+
223+ // Check child elements - select all elements with the attribute (any value or no value)
224+ const children = node.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`);
225+ for (const child of children) {
226+ if (hasMacroAttribute(child) && (child instanceof HTMLTextAreaElement || child instanceof HTMLInputElement)) {
227+ initializeElement(child);
228+ }
229+ }
230+}
231+
232+/**
233+ * MutationObserver to watch for dynamically added elements with macro autocomplete attribute.
234+ * @type {MutationObserver}
235+ */
236+const observer = new MutationObserver(mutations => {
237+ for (const mutation of mutations) {
238+ if (mutation.type === 'childList') {
239+ for (const node of mutation.addedNodes) {
240+ handleNodeChange(node);
241+ }
242+ }
243+ if (mutation.type === 'attributes') {
244+ const target = mutation.target;
245+ const isRelevantAttr = mutation.attributeName === MACRO_AUTOCOMPLETE_ATTRIBUTE ||
246+ mutation.attributeName === MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE ||
247+ mutation.attributeName === MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE;
248+ if (isRelevantAttr && target instanceof Element) {
249+ handleNodeChange(target);
250+ }
251+ }
252+ }
253+});
254+
255+/**
256+ * Initializes macro autocomplete for all elements with the `data-macros` attribute.
257+ * Also starts the MutationObserver to watch for dynamically added elements.
258+ * Should be called after DOM is ready.
259+ *
260+ * @returns {AutoComplete[]} Array of autocomplete instances created.
261+ */
262+export function initMacroAutoComplete() {
263+ const elements = /** @type {NodeListOf<HTMLTextAreaElement|HTMLInputElement>} */ (
264+ document.querySelectorAll(`[${MACRO_AUTOCOMPLETE_ATTRIBUTE}]`)
265+ );
266+
267+ const instances = [];
268+ for (const element of elements) {
269+ if (hasMacroAttribute(element)) {
270+ const ac = initializeElement(element);
271+ if (ac) {
272+ instances.push(ac);
273+ }
274+ }
275+ }
276+
277+ // Start observing for dynamically added elements
278+ observer.observe(document.body, {
279+ childList: true,
280+ subtree: true,
281+ attributes: true,
282+ attributeFilter: [MACRO_AUTOCOMPLETE_ATTRIBUTE, MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE, MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE],
283+ });
284+
285+ return instances;
286+}
287+
288+/**
289+ * Enables macro autocomplete on a specific element by ID.
290+ * Adds the attribute and initializes autocomplete.
291+ *
292+ * @param {string} elementId - The element ID (without #).
293+ * @returns {AutoComplete|null} The autocomplete instance, or null if element not found.
294+ */
295+export function enableMacroAutoCompleteById(elementId) {
296+ const element = /** @type {HTMLTextAreaElement|HTMLInputElement|null} */ (
297+ document.getElementById(elementId)
298+ );
299+
300+ if (!element || !(element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement)) {
301+ console.warn(`[MacroAutoComplete] Element not found or invalid: ${elementId}`);
302+ return null;
303+ }
304+
305+ element.setAttribute(MACRO_AUTOCOMPLETE_ATTRIBUTE, 'true');
306+ return initializeElement(element);
307+}
public/scripts/autocomplete/MacroAutoCompleteHelper.js+958 -0
@@ -0,0 +1,958 @@
1+/**
2+ * Shared utilities for macro autocomplete functionality.
3+ * Used by both SlashCommandParser (for slash command context) and MacroAutoComplete (for free text).
4+ *
5+ * This module extracts common macro autocomplete logic to avoid duplication and ensure
6+ * consistent behavior across all contexts where macro autocomplete is used.
7+ */
8+
9+import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
10+import {
11+ EnhancedMacroAutoCompleteOption,
12+ MacroFlagAutoCompleteOption,
13+ MacroClosingTagAutoCompleteOption,
14+ VariableShorthandAutoCompleteOption,
15+ VariableShorthandDefinitions,
16+ VariableNameAutoCompleteOption,
17+ VariableOperatorAutoCompleteOption,
18+ VariableValueContextAutoCompleteOption,
19+ VariableOperatorDefinitions,
20+ isValidVariableShorthandName,
21+ parseMacroContext,
22+ SimpleAutoCompleteOption,
23+} from './EnhancedMacroAutoCompleteOption.js';
24+import { macros as macroSystem } from '../macros/macro-system.js';
25+import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
26+import { MacroParser } from '../macros/engine/MacroParser.js';
27+import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
28+import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
29+import { chat_metadata } from '/script.js';
30+import { extension_settings } from '../extensions.js';
31+
32+/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
33+/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
34+/** @typedef {import('./AutoCompleteOption.js').AutoCompleteOption} AutoCompleteOption */
35+/*** @typedef {import('../macros/macro-system.js').MacroDefinition} MacroDefinition */
36+
37+/**
38+ * @typedef {Object} MacroInfo
39+ * @property {number} start - Start position of the macro in text (at first {)
40+ * @property {number} end - End position of the macro in text (after last })
41+ * @property {string} content - The content between {{ and }}
42+ */
43+
44+/**
45+ * @typedef {Object} UnclosedScope
46+ * @property {string} name - Macro name
47+ * @property {number} startOffset - Start position in text
48+ * @property {number} endOffset - End position of opening tag
49+ * @property {string} paddingBefore - Whitespace before macro name
50+ * @property {string} paddingAfter - Whitespace after macro content
51+ */
52+
53+/**
54+ * @typedef {Object} BuildMacroAutoCompleteOptions
55+ * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro
56+ * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor
57+ * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes
58+ */
59+
60+/**
61+ * Finds unclosed scoped macros in the text up to cursor position.
62+ * Uses the MacroParser and MacroCstWalker for accurate analysis.
63+ *
64+ * @param {string} textUpToCursor - The document text up to the cursor position.
65+ * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
66+ */
67+export function findUnclosedScopes(textUpToCursor) {
68+ if (!textUpToCursor) return [];
69+
70+ try {
71+ // Parse the document to get the CST
72+ const { cst } = MacroParser.parseDocument(textUpToCursor);
73+ if (!cst) return [];
74+
75+ // Use the CST walker to find unclosed scopes
76+ return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst });
77+ } catch {
78+ // If parsing fails (incomplete input), fall back to simple regex approach
79+ return findUnclosedScopesRegex(textUpToCursor);
80+ }
81+}
82+
83+/**
84+ * Fallback regex-based approach for finding unclosed scopes.
85+ * Used when the parser fails on incomplete input.
86+ *
87+ * @param {string} text - The text to analyze.
88+ * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
89+ */
90+export function findUnclosedScopesRegex(text) {
91+ // Regex to find macro openings and closings, capturing whitespace padding
92+ // Group 1: padding after {{, Group 2: optional /, Group 3: macro name
93+ const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g;
94+ const stack = [];
95+
96+ let match;
97+ while ((match = macroPattern.exec(text)) !== null) {
98+ const paddingBefore = match[1];
99+ const isClosing = match[2] === '/';
100+ const name = match[3];
101+
102+ if (isClosing) {
103+ // Pop matching opener (case-insensitive)
104+ if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) {
105+ stack.pop();
106+ }
107+ } else {
108+ // Check if macro can accept scoped content
109+ const macroDef = macroSystem.registry.getPrimaryMacro(name);
110+ if (macroDef && macroDef.maxArgs > 0) {
111+ // Try to find closing }} to extract trailing whitespace
112+ let paddingAfter = '';
113+ const afterMatch = text.slice(match.index + match[0].length);
114+ const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/);
115+ if (closingMatch) {
116+ paddingAfter = closingMatch[1];
117+ }
118+
119+ stack.push({
120+ name,
121+ startOffset: match.index,
122+ endOffset: match.index + match[0].length,
123+ paddingBefore,
124+ paddingAfter,
125+ });
126+ }
127+ }
128+ }
129+
130+ return stack;
131+}
132+
133+/**
134+ * Builds autocomplete options for variable shorthand syntax (.varName or $varName).
135+ * @param {MacroAutoCompleteContext} context
136+ * @param {Object} [opts] - Optional configuration.
137+ * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
138+ * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
139+ * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
140+ */
141+export function buildVariableShorthandOptions(context, opts = {}) {
142+ const { forIfCondition = false, paddingAfter = '' } = opts;
143+ /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption)[]} */
144+ const options = [];
145+
146+ const isLocal = context.variablePrefix === '.';
147+ const scope = isLocal ? 'local' : 'global';
148+
149+
150+ // Always show the typed variable prefix as a non-completable option (like flags do)
151+ // This allows the details panel to show information about the prefix
152+ const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix);
153+ if (prefixDef) {
154+ const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
155+ prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
156+ prefixOption.makeSelectable = false;
157+ prefixOption.sortPriority = 1; // Show at top
158+ prefixOption.matchProvider = () => true; // Always show regardless of filtering
159+ options.push(prefixOption);
160+ }
161+
162+ // If typing the variable name, suggest existing variables
163+ // Get existing variable names from the appropriate scope
164+ // Filter to only include names that are valid for shorthand syntax
165+ const existingVariables = getVariableNames(scope)
166+ .filter(name => isValidVariableShorthandName(name));
167+
168+ // Check if the typed variable name exactly matches an existing variable
169+ const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName);
170+
171+ if (context.isTypingVariableName) {
172+ // Add existing variables that match the typed name
173+ for (const varName of existingVariables) {
174+ const option = new VariableNameAutoCompleteOption(varName, scope, false);
175+ // Not selectable if it matches the typed name
176+ if (varName === context.variableName) {
177+ option.valueProvider = () => '';
178+ option.makeSelectable = false;
179+ }
180+ // For {{if}} condition, provide full value with closing braces
181+ if (forIfCondition) {
182+ option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already.
183+ option.makeSelectable = true;
184+ }
185+ // Variables matching the typed prefix get higher priority
186+ option.sortPriority = varName.startsWith(context.variableName) ? 3 : 10;
187+ options.push(option);
188+ }
189+
190+ // If typing a name that doesn't exist, offer to create a new variable
191+ // But if the name is invalid for shorthand syntax, show a warning instead
192+ if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) {
193+ const isInvalid = !isValidVariableShorthandName(context.variableName);
194+ const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid);
195+ newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning
196+ if (isInvalid) {
197+ // Make it non-selectable since it can't be used
198+ newVarOption.valueProvider = () => '';
199+ newVarOption.makeSelectable = false;
200+ } else if (forIfCondition) {
201+ // For {{if}} condition, provide full value with closing braces
202+ newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`;
203+ newVarOption.makeSelectable = true;
204+ }
205+ options.push(newVarOption);
206+ }
207+
208+ // If the typed variable name exactly matches an existing variable, also show operators
209+ // This allows users to see available operators without having to type a space first
210+ if (variableNameMatchesExisting) {
211+ for (const [, operatorDef] of VariableOperatorDefinitions) {
212+ const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
213+ opOption.sortPriority = 6; // Lower priority than variable suggestions
214+ opOption.matchProvider = () => true; // Always show
215+ // IMPORTANT: Operators should INSERT after variable name, not replace it
216+ // Use replacementStartOffset to shift insertion point past the variable name
217+ opOption.replacementStartOffset = context.variableName.length;
218+ options.push(opOption);
219+ }
220+ }
221+ }
222+
223+ // If there are invalid trailing characters after the variable name, show a warning
224+ if (context.hasInvalidTrailingChars) {
225+ // Show the full invalid name (variableName + invalidTrailingChars) with a warning
226+ const fullInvalidName = context.variableName + (context.invalidTrailingChars || '');
227+ const invalidOption = new VariableNameAutoCompleteOption(
228+ fullInvalidName,
229+ scope,
230+ false,
231+ true, // isInvalidName - triggers warning display
232+ );
233+ invalidOption.valueProvider = () => ''; // Don't insert anything
234+ invalidOption.makeSelectable = false;
235+ invalidOption.sortPriority = 2;
236+ invalidOption.matchProvider = () => true; // Always show
237+ options.push(invalidOption);
238+ // Return early - don't show operators when syntax is invalid
239+ return options;
240+ }
241+
242+ // If ready for operator (after variable name), suggest operators
243+ if (context.isTypingOperator) {
244+ // Show the current variable name as context (already typed)
245+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
246+ varNameOption.valueProvider = () => ''; // Already typed, don't re-insert
247+ varNameOption.makeSelectable = false;
248+ varNameOption.sortPriority = 2;
249+ varNameOption.matchProvider = () => true; // Always show
250+ options.push(varNameOption);
251+
252+ // Then show available operators, filtered by partial prefix if any
253+ // Also filter by current complete operator to show longer variants (e.g., > shows >=)
254+ const partialOp = context.partialOperator || '';
255+ const currentOp = context.variableOperator || '';
256+ const filterPrefix = partialOp || currentOp;
257+ for (const [, operatorDef] of VariableOperatorDefinitions) {
258+ // Filter by operator prefix if user is typing one
259+ // This allows typing ">" to show both ">" and ">="
260+ if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) {
261+ continue;
262+ }
263+ const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
264+ // Exact match gets higher priority
265+ opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5;
266+ // Already-typed operator is non-selectable
267+ if (operatorDef.symbol === currentOp) {
268+ opOption.valueProvider = () => '';
269+ opOption.makeSelectable = false;
270+ }
271+ // Always match operators when showing operator suggestions
272+ opOption.matchProvider = () => true;
273+ options.push(opOption);
274+ }
275+ }
276+
277+ // If typing value (after = or +=), no autocomplete needed - freeform text
278+ // But we show the current context for reference (greyed out, non-selectable)
279+ if (context.isTypingValue && !context.isTypingOperator) {
280+ // Show the current variable name as context (non-selectable)
281+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
282+ varNameOption.valueProvider = () => ''; // Context only
283+ varNameOption.makeSelectable = false;
284+ varNameOption.sortPriority = 2;
285+ varNameOption.matchProvider = () => true; // Always show
286+ options.push(varNameOption);
287+
288+ // Show the operator that was used (non-selectable)
289+ if (context.variableOperator) {
290+ const opDef = VariableOperatorDefinitions.get(context.variableOperator);
291+ if (opDef) {
292+ const opOption = new VariableOperatorAutoCompleteOption(opDef);
293+ opOption.valueProvider = () => ''; // Already typed
294+ opOption.makeSelectable = false;
295+ opOption.sortPriority = 3;
296+ opOption.matchProvider = () => true; // Always show
297+ options.push(opOption);
298+
299+ // Show value context info (non-selectable)
300+ const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue);
301+ valueOption.valueProvider = () => ''; // Context only
302+ valueOption.makeSelectable = false;
303+ valueOption.sortPriority = 4;
304+ valueOption.matchProvider = () => true; // Always show
305+ options.push(valueOption);
306+ }
307+ }
308+ }
309+
310+ // If operator is complete (++ or --), show context without value input (non-selectable)
311+ if (context.isOperatorComplete && !context.isTypingOperator) {
312+ // Show the current variable name as context (non-selectable)
313+ const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
314+ varNameOption.valueProvider = () => ''; // Context only
315+ varNameOption.makeSelectable = false;
316+ varNameOption.sortPriority = 2;
317+ varNameOption.matchProvider = () => true; // Always show
318+ options.push(varNameOption);
319+
320+ // Show the operator that was used (non-selectable)
321+ if (context.variableOperator) {
322+ const opDef = VariableOperatorDefinitions.get(context.variableOperator);
323+ if (opDef) {
324+ const opOption = new VariableOperatorAutoCompleteOption(opDef);
325+ opOption.valueProvider = () => ''; // Already typed
326+ opOption.makeSelectable = false;
327+ opOption.sortPriority = 3;
328+ opOption.matchProvider = () => true; // Always show
329+ options.push(opOption);
330+ }
331+ }
332+ }
333+
334+ return options;
335+}
336+
337+/**
338+ * Builds enhanced macro autocomplete options from the MacroRegistry.
339+ * When in the flags area (before identifier), includes flag options.
340+ * When typing arguments (after ::), prioritizes the exact macro match.
341+ * @param {MacroAutoCompleteContext} context
342+ * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
343+ * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
344+ */
345+export function buildEnhancedMacroOptions(context, textUpToCursor) {
346+ /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
347+ const options = [];
348+
349+ if (context.isVariableShorthand) {
350+ return buildVariableShorthandOptions(context);
351+ }
352+
353+ // Check for unclosed scoped macros and suggest closing tags first
354+ const unclosedScopes = findUnclosedScopes(textUpToCursor);
355+ if (unclosedScopes.length > 0) {
356+ // Suggest closing the innermost (last) unclosed scope first
357+ const innermostScope = unclosedScopes[unclosedScopes.length - 1];
358+ // Preserve whitespace padding from the opening tag
359+ // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding
360+ const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name, {
361+ paddingBefore: innermostScope.paddingBefore,
362+ paddingAfter: innermostScope.paddingAfter,
363+ currentPadding: context.paddingBefore,
364+ });
365+ options.push(closingOption);
366+
367+ // If inside a scoped {{if}}, also suggest {{else}}
368+ if (innermostScope.name === 'if') {
369+ const macroDef = macroSystem.registry.getPrimaryMacro('else');
370+ const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
371+ elseOption.sortPriority = 2;
372+ options.push(elseOption);
373+ }
374+ }
375+
376+ // If cursor is in the flags area (before identifier starts), include flag options
377+ if (context.isInFlagsArea) {
378+ // Build flag options with priority-based sorting
379+ // Last typed flag has highest priority (1), other flags have lower priority (10)
380+ // Already-typed flags (except last) are hidden from the list
381+ const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null;
382+
383+ // Add last typed flag with high priority (so it appears at top)
384+ if (lastTypedFlag) {
385+ const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag);
386+ if (lastFlagDef) {
387+ const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef);
388+ // Mark as already typed - valueProvider returns empty so it doesn't re-insert
389+ lastFlagOption.valueProvider = () => '';
390+ lastFlagOption.makeSelectable = false;
391+ // High priority to appear at top (after closing tags at 1)
392+ lastFlagOption.sortPriority = 2;
393+ options.push(lastFlagOption);
394+ }
395+ }
396+
397+ // Add flags that haven't been typed yet (skip already-typed ones except last)
398+ for (const [symbol, flagDef] of MacroFlagDefinitions) {
399+ // Skip the last typed flag (already added above) and other already-typed flags
400+ if (context.flags.includes(symbol)) {
401+ continue;
402+ }
403+ const flagOption = new MacroFlagAutoCompleteOption(flagDef);
404+
405+ // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant
406+ let isSelectable = flagDef.implemented;
407+ if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false;
408+ if (!isSelectable) {
409+ flagOption.valueProvider = () => '';
410+ flagOption.makeSelectable = false;
411+ }
412+ // Normal flag priority
413+ flagOption.sortPriority = isSelectable ? 10 : 12;
414+ options.push(flagOption);
415+ }
416+
417+ // Add variable shorthand prefix options (. for local, $ for global)
418+ // These allow users to type variable shorthands instead of macro names
419+ for (const [, varShorthandDef] of VariableShorthandDefinitions) {
420+ const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef);
421+ varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12)
422+ options.push(varOption);
423+ }
424+ }
425+
426+ // Get all macros from the registry (excluding hidden aliases)
427+ const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
428+
429+ // If we're typing arguments (after ::), only show the context to the matching macro
430+ const isTypingArgs = context.currentArgIndex >= 0;
431+
432+ // Check if we're inside a scoped {{if}} for {{else}} selectability
433+ const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
434+
435+ for (const macro of allMacros) {
436+ // Check if this macro matches the typed identifier
437+ const isExactMatch = macro.name === context.identifier;
438+ const isAliasMatch = macro.aliasOf === context.identifier;
439+
440+ // Only pass context to the macro that matches the identifier being typed
441+ // This ensures argument hints only show for the relevant macro
442+ /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
443+ let macroContext = (isExactMatch || isAliasMatch) ? context : null;
444+
445+ // If no context, we pass some options for additional details though
446+ if (!macroContext) {
447+ macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
448+ paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed
449+ flags: context.flags,
450+ currentFlag: context.currentFlag,
451+ fullText: context.fullText,
452+ });
453+ }
454+
455+ const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
456+
457+ // {{else}} is only selectable inside a scoped {{if}} block
458+ // Outside of {{if}}, it should appear in the list but not be tab-completable
459+ if (macro.name === 'else' && !isInsideScopedIf) {
460+ option.valueProvider = () => '';
461+ option.makeSelectable = false;
462+ }
463+
464+ // When typing arguments, prioritize exact matches by putting them first
465+ if (isTypingArgs && (isExactMatch || isAliasMatch)) {
466+ options.unshift(option);
467+ } else {
468+ options.push(option);
469+ }
470+ }
471+
472+ return options;
473+}
474+
475+/**
476+ * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
477+ * @param {MacroAutoCompleteContext} context
478+ * @param {MacroDefinition[]} allMacros
479+ * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers").
480+ * @returns {AutoCompleteOption[]}
481+ */
482+export function buildIfConditionOptions(context, allMacros, macroInnerText) {
483+ /** @type {AutoCompleteOption[]} */
484+ const options = [];
485+
486+ // Calculate padding from the original macro text for matching whitespace on completion
487+ // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}')
488+ const leadingMatch = macroInnerText.match(/^(\s*)/);
489+ const paddingAfter = leadingMatch ? leadingMatch[1] : '';
490+
491+ // Get the condition text being typed (trimmed for detection)
492+ const conditionText = (context.args[0] || '').trim();
493+
494+ // Check for inversion prefix (!) - also trim whitespace after !
495+ const hasInversionPrefix = conditionText.startsWith('!');
496+ const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText;
497+
498+ const inversionOption = new SimpleAutoCompleteOption({
499+ name: '!',
500+ symbol: '🔁',
501+ description: 'Invert condition (NOT)',
502+ detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.',
503+ type: 'inverse',
504+ });
505+
506+ // Check if condition starts with a variable shorthand prefix (with or without !)
507+ const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
508+
509+ if (isTypingVariableShorthand) {
510+ // User is typing a variable shorthand - reuse #buildVariableShorthandOptions
511+ const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]);
512+ const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix
513+
514+ // If inverted, show the ! as non-selectable context
515+ if (hasInversionPrefix) {
516+ inversionOption.valueProvider = () => ''; // Already typed
517+ inversionOption.makeSelectable = false;
518+ inversionOption.sortPriority = 0;
519+ options.push(inversionOption);
520+ }
521+
522+ // Create a synthetic context for #buildVariableShorthandOptions
523+ /** @type {MacroAutoCompleteContext} */
524+ const varContext = {
525+ ...context,
526+ isVariableShorthand: true,
527+ variablePrefix: prefix,
528+ variableName: varNameTyped,
529+ isTypingVariableName: true,
530+ isTypingOperator: false,
531+ isTypingValue: false,
532+ isOperatorComplete: false,
533+ hasInvalidTrailingChars: false,
534+ variableOperator: null,
535+ variableValue: '',
536+ };
537+
538+ const varOptions = buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter });
539+ options.push(...varOptions);
540+ return options;
541+ }
542+
543+ // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion
544+
545+ // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed
546+ if (conditionText.length === 0) {
547+ // Nothing typed - offer ! as selectable option
548+ inversionOption.valueProvider = () => '!';
549+ inversionOption.makeSelectable = true;
550+ inversionOption.sortPriority = -1; // Show at very top
551+ options.push(inversionOption);
552+ } else if (hasInversionPrefix && conditionAfterInversion.length === 0) {
553+ // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes
554+ inversionOption.valueProvider = () => ''; // Already typed
555+ inversionOption.makeSelectable = false;
556+ inversionOption.sortPriority = -1;
557+ options.push(inversionOption);
558+ }
559+
560+ // Add variable shorthand prefix options when no content typed yet (or just ! typed)
561+ if (conditionAfterInversion.length === 0) {
562+ for (const [, prefixDef] of VariableShorthandDefinitions) {
563+ const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
564+ // Complete with just the prefix symbol
565+ prefixOption.valueProvider = () => prefixDef.type;
566+ prefixOption.makeSelectable = true;
567+ prefixOption.sortPriority = 0; // Show at top
568+ options.push(prefixOption);
569+ }
570+ }
571+
572+ // Add zero-arg macros as condition shorthand options
573+ for (const macro of allMacros) {
574+ // Only include macros that require zero arguments (can be auto-resolved)
575+ if (macro.minArgs !== 0) continue;
576+
577+ // Skip internal/utility macros that don't make sense as conditions
578+ if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue;
579+
580+ const option = new EnhancedMacroAutoCompleteOption(macro, {
581+ noBraces: true,
582+ paddingAfter,
583+ closeWithBraces: true,
584+ });
585+ options.push(option);
586+ }
587+
588+ return options;
589+}
590+
591+/**
592+ * Finds macro boundaries at a given cursor position in any text.
593+ * Works independently of slash command parsing.
594+ *
595+ * @param {string} text - The full text content.
596+ * @param {number} cursorPos - The cursor position in the text.
597+ * @returns {{ start: number, end: number, content: string } | null}
598+ */
599+export function findMacroAtCursor(text, cursorPos) {
600+ // Search backwards for opening {{ while tracking nesting depth for nested macros
601+ let openPos = -1;
602+ let depth = 0;
603+ for (let i = cursorPos - 1; i >= 0; i--) {
604+ if (text[i] === '}' && i > 0 && text[i - 1] === '}') {
605+ // Found }}, going backwards means we're entering a nested macro
606+ depth++;
607+ i--; // Skip the other brace
608+ continue;
609+ }
610+ if (text[i] === '{' && i > 0 && text[i - 1] === '{') {
611+ if (depth > 0) {
612+ // This {{ closes a nested macro we entered going backwards
613+ depth--;
614+ i--; // Skip the other brace
615+ continue;
616+ }
617+ // Found our opening {{ at depth 0
618+ openPos = i - 1;
619+ break;
620+ }
621+ }
622+
623+ if (openPos === -1) return null;
624+
625+ // Search forwards for closing }} while tracking nesting depth
626+ let closePos = -1;
627+ depth = 0;
628+ for (let i = cursorPos; i < text.length - 1; i++) {
629+ if (text[i] === '{' && text[i + 1] === '{') {
630+ // Found {{, entering a nested macro
631+ depth++;
632+ i++; // Skip the other brace
633+ continue;
634+ }
635+ if (text[i] === '}' && text[i + 1] === '}') {
636+ if (depth > 0) {
637+ // This }} closes a nested macro
638+ depth--;
639+ i++; // Skip the other brace
640+ continue;
641+ }
642+ // Found our closing }} at depth 0
643+ closePos = i + 2;
644+ break;
645+ }
646+ }
647+
648+ if (closePos === -1) {
649+ closePos = text.length;
650+ }
651+
652+ const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}';
653+ const content = text.slice(openPos + 2, hasClosingBraces ? closePos - 2 : closePos);
654+
655+ return {
656+ start: openPos,
657+ end: closePos,
658+ content,
659+ };
660+}
661+
662+/**
663+ * Gets variable names from the specified scope.
664+ *
665+ * @param {'local'|'global'} scope - The variable scope.
666+ * @returns {string[]} Array of variable names.
667+ */
668+export function getVariableNames(scope) {
669+ try {
670+ // Import chat_metadata and extension_settings dynamically to avoid circular deps
671+ // These are the same sources used by commonEnumProviders.variables
672+ if (scope === 'local') {
673+ // Local variables are in chat_metadata.variables
674+ return Object.keys(chat_metadata?.variables ?? {});
675+ } else {
676+ // Global variables are in extension_settings.variables.global
677+ return Object.keys(extension_settings?.variables?.global ?? {});
678+ }
679+ } catch {
680+ return [];
681+ }
682+}
683+
684+/**
685+ * Core function to build macro autocomplete results.
686+ * Used by both SlashCommandParser (slash command context) and MacroAutoComplete (free text).
687+ *
688+ * This is the shared implementation that handles:
689+ * - Scoped content detection and context display
690+ * - {{if}} condition special handling
691+ * - Variable shorthand syntax (.var, $var)
692+ * - Flag handling
693+ * - Regular macro options
694+ *
695+ * @param {string} text - The full text content.
696+ * @param {number} cursorPos - The cursor position.
697+ * @param {BuildMacroAutoCompleteOptions} [options={}] - Optional pre-computed values.
698+ * @returns {Promise<AutoCompleteNameResult|null>}
699+ */
700+export async function buildMacroAutoCompleteResult(text, cursorPos, {
701+ macro = null,
702+ textUpToCursor = null,
703+ unclosedScopes = null,
704+} = {}) {
705+ // Compute textUpToCursor if not provided
706+ if (textUpToCursor === null) {
707+ textUpToCursor = text.slice(0, cursorPos);
708+ }
709+
710+ // Compute unclosedScopes if not provided
711+ if (unclosedScopes === null) {
712+ unclosedScopes = findUnclosedScopes(textUpToCursor);
713+ }
714+
715+ // If cursor is NOT inside a macro, check if we're in scoped content
716+ if (!macro) {
717+ if (unclosedScopes.length > 0) {
718+ const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
719+
720+ // Find where the opening macro ends
721+ const openingEnd = text.indexOf('}}', scopedMacro.startOffset);
722+ if (openingEnd !== -1 && cursorPos >= openingEnd + 2) {
723+ // We're in scoped content - show parent macro's details
724+ const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd);
725+ const baseContext = parseMacroContext(macroContent, macroContent.length);
726+
727+ const scopedContext = {
728+ ...baseContext,
729+ currentArgIndex: baseContext.args.length,
730+ isInScopedContent: true,
731+ scopedMacroName: scopedMacro.name,
732+ };
733+
734+ await onboardingExperimentalMacroEngine('scoped macros');
735+
736+ const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
737+ if (macroDef) {
738+ const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
739+ scopedOption.valueProvider = () => '';
740+ scopedOption.makeSelectable = false;
741+
742+ return new AutoCompleteNameResult(
743+ scopedMacro.name,
744+ scopedMacro.startOffset + 2,
745+ [scopedOption],
746+ false,
747+ );
748+ }
749+ }
750+ }
751+ return null;
752+ }
753+
754+ // Cursor is inside a macro - parse context
755+ const cursorInMacro = cursorPos - macro.start - 2;
756+ const context = parseMacroContext(macro.content, cursorInMacro);
757+
758+ // Check if cursor is at/after closing }}
759+ const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}';
760+ const isCursorAtClosing = macroEndsBrackets && cursorPos >= macro.end - 1;
761+
762+ if (isCursorAtClosing) {
763+ // Cursor is at the closing }} - check if this is an unclosed scoped macro
764+ if (unclosedScopes.length > 0) {
765+ const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
766+ // Check if the current macro IS the unclosed scoped macro
767+ if (scopedMacro.startOffset === macro.start) {
768+ // Show scoped context - cursor is right at the end of the opening tag
769+ const scopedContext = {
770+ ...context,
771+ currentArgIndex: context.args.length,
772+ isInScopedContent: true,
773+ scopedMacroName: scopedMacro.name,
774+ };
775+
776+ const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
777+ if (macroDef) {
778+ const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
779+ scopedOption.valueProvider = () => '';
780+ scopedOption.makeSelectable = false;
781+
782+ return new AutoCompleteNameResult(
783+ scopedMacro.name,
784+ macro.start + 2,
785+ [scopedOption],
786+ false,
787+ );
788+ }
789+ }
790+ }
791+ // Not a scoped macro, just clear arg highlighting
792+ context.currentArgIndex = -1;
793+ }
794+
795+ // Use the identifier from context (handles whitespace and flags)
796+ // Start position must be where the identifier actually begins (after whitespace/flags)
797+ // so that the autocomplete range calculation works correctly
798+ const identifier = context.identifier;
799+ const identifierStartInText = macro.start + 2 + context.identifierStart;
800+
801+ // Special case for {{if}} condition: use the condition text for matching/replacement
802+ const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0;
803+ if (isTypingIfCondition) {
804+ // Get the typed condition text and calculate its start position
805+ const conditionText = context.args[0] || '';
806+ // Find where the condition argument starts in the macro text
807+ const separatorMatch = macro.content.match(/^.*?if\s*(?:::?)\s*/);
808+ const spaceMatch = macro.content.match(/^.*?if\s+/);
809+ let conditionStartOffset;
810+ if (separatorMatch) {
811+ conditionStartOffset = separatorMatch[0].length;
812+ } else if (spaceMatch) {
813+ conditionStartOffset = spaceMatch[0].length;
814+ } else {
815+ conditionStartOffset = context.identifierStart + identifier.length;
816+ }
817+ const conditionStartInText = macro.start + 2 + conditionStartOffset;
818+
819+ // Build if-condition options using macroContent for padding calculation
820+ const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
821+ const options = buildIfConditionOptions(context, allMacros, macro.content);
822+
823+ // For variable shorthand in {{if}} condition, adjust identifier and start position
824+ // Same fix as for regular variable shorthands - identifier must be just the var name
825+ // Also handle ! inversion prefix: !.var or !$var or !macroName
826+ const trimmedCondition = conditionText.trim();
827+ const hasInversion = trimmedCondition.startsWith('!');
828+ // Trim whitespace after ! to handle "! $myvar" syntax
829+ const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition;
830+ const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
831+ let resultIdentifier = conditionText;
832+ let resultStart = conditionStartInText;
833+
834+ if (isTypingVarShorthand) {
835+ // Identifier = just the variable name part (without prefix and without !)
836+ resultIdentifier = conditionAfterInversion.slice(1);
837+ // Start = after the ! (if any) and the prefix
838+ const prefixChar = conditionAfterInversion[0];
839+ const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0);
840+ resultStart = conditionStartInText + prefixPosInCondition + 1;
841+ } else if (hasInversion && conditionAfterInversion.length === 0) {
842+ // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match
843+ resultIdentifier = '';
844+ // Start at end of actual condition text (including any whitespace after !)
845+ // This ensures cursor is within the name range for filtering
846+ resultStart = conditionStartInText + conditionText.length;
847+ } else if (hasInversion && conditionAfterInversion.length > 0) {
848+ // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name
849+ resultIdentifier = conditionAfterInversion;
850+ // Start = after the ! and any whitespace, at the beginning of the macro name
851+ const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion);
852+ resultStart = conditionStartInText + macroNameStart;
853+ }
854+
855+ await onboardingExperimentalMacroEngine('{{if}} macro');
856+
857+ return new AutoCompleteNameResult(
858+ resultIdentifier,
859+ resultStart,
860+ options,
861+ false,
862+ () => isTypingVarShorthand
863+ ? 'Enter a variable name for the condition'
864+ : 'Use {{macro}} syntax for dynamic conditions',
865+ () => isTypingVarShorthand
866+ ? 'Enter a variable name or select from the list'
867+ : 'Enter a macro name or {{macro}} for the condition',
868+ );
869+ }
870+
871+ // Build regular macro options
872+ /** @type {()=>string|undefined} */
873+ let makeNoMatchText = undefined;
874+ /** @type {()=>string|undefined} */
875+ let makeNoOptionsText = undefined;
876+
877+ const options = buildEnhancedMacroOptions(context, textUpToCursor);
878+
879+ // For variable shorthands, calculate the correct identifier and start position
880+ // based on what the user is currently typing (variable name, operator, or value)
881+ let resultIdentifier = identifier;
882+ let resultStart = identifierStartInText;
883+ if (context.isVariableShorthand && context.variablePrefix) {
884+ // Find where the prefix is in the macro content
885+ const prefixIndex = macro.content.indexOf(context.variablePrefix);
886+
887+ if (context.isTypingVariableName) {
888+ // Typing variable name: identifier = variableName, start = after prefix
889+ resultIdentifier = context.variableName;
890+ if (prefixIndex >= 0) {
891+ resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
892+ }
893+ } else if (context.isTypingOperator) {
894+ // Typing operator: identifier = partial operator or current operator, start = after variable name
895+ resultIdentifier = context.partialOperator || context.variableOperator || '';
896+ // Use actual variableNameEnd position from parsing (accounts for whitespace)
897+ resultStart = macro.start + 2 + context.variableNameEnd;
898+ // Skip whitespace between variable name and operator
899+ while (resultStart < cursorPos && /\s/.test(text[resultStart])) {
900+ resultStart++;
901+ }
902+ } else if (context.isOperatorComplete) {
903+ // Operator complete (++ or --) - show context but no value input needed
904+ resultIdentifier = '';
905+ resultStart = cursorPos; // Cursor at end
906+ } else if (context.hasInvalidTrailingChars) {
907+ // Invalid chars after variable name: show the invalid chars for warning
908+ resultIdentifier = context.invalidTrailingChars || '';
909+ // Use actual variableNameEnd position from parsing
910+ resultStart = macro.start + 2 + context.variableNameEnd;
911+ } else if (context.isTypingValue) {
912+ // Typing value: identifier = value being typed, start = after operator
913+ resultIdentifier = context.variableValue;
914+ // Use actual operatorEnd position from parsing (accounts for whitespace)
915+ resultStart = macro.start + 2 + context.variableOperatorEnd;
916+ // Skip any whitespace between operator and value
917+ while (resultStart < cursorPos && /\s/.test(text[resultStart])) {
918+ resultStart++;
919+ }
920+
921+ makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
922+ makeNoOptionsText = () => 'Enter a variable value';
923+ } else {
924+ // Fallback: use variable name
925+ resultIdentifier = context.variableName;
926+ if (prefixIndex >= 0) {
927+ resultStart = macro.start + 2 + prefixIndex + 1;
928+ }
929+ }
930+
931+ if (!makeNoMatchText && !makeNoOptionsText) {
932+ makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.';
933+ makeNoOptionsText = () => 'Enter a variable name to create or use a new variable';
934+ }
935+ }
936+
937+ return new AutoCompleteNameResult(
938+ resultIdentifier,
939+ resultStart,
940+ options,
941+ false,
942+ makeNoMatchText,
943+ makeNoOptionsText,
944+ );
945+}
946+
947+/**
948+ * Entry point for macro autocomplete in free text contexts.
949+ * Finds the macro at cursor position and delegates to the shared builder.
950+ *
951+ * @param {string} text - The full text content.
952+ * @param {number} cursorPos - The cursor position.
953+ * @returns {Promise<AutoCompleteNameResult|null>}
954+ */
955+export async function getMacroAutoCompleteAt(text, cursorPos) {
956+ const macro = findMacroAtCursor(text, cursorPos);
957+ return buildMacroAutoCompleteResult(text, cursorPos, { macro });
958+}
public/scripts/chats.js+5 -0
@@ -2239,6 +2239,11 @@ export function initChatUtilities() {
22392239 wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
22402240 const textarea = document.createElement('textarea');
22412241 textarea.dataset.for = broId;
2242+ if (bro[0].dataset.macros !== undefined) {
2243+ textarea.dataset.macros = bro[0].dataset.macros;
2244+ textarea.dataset.macrosAutocomplete = 'always'; // Always show autocomplete in expanded editor
2245+ textarea.dataset.macrosAutocompleteStyle = 'expanded'; // Use expanded autocomplete style
2246+ }
22422247 textarea.value = String(contentEditable ? bro[0].innerText : bro.val());
22432248 textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
22442249 bro.hasClass('monospace') && textarea.classList.add('monospace');
public/scripts/power-user.js+11 -0
@@ -326,6 +326,8 @@ export const power_user = {
326326 right: AUTOCOMPLETE_WIDTH.CHAT,
327327 },
328328 select: AUTOCOMPLETE_SELECT_KEY.TAB + AUTOCOMPLETE_SELECT_KEY.ENTER,
329+ /** Whether to show macro autocomplete in all macro-enabled fields (not just expanded editors) */
330+ showInAllMacroFields: false,
329331 },
330332 parser: {
331333 /**@type {Object.<PARSER_FLAG,boolean>} */
@@ -1590,6 +1592,9 @@ export async function loadPowerUserSettings(settings, data) {
15901592 if (power_user.stscript.autocomplete.select === undefined) {
15911593 power_user.stscript.autocomplete.select = defaultStscript.autocomplete.select;
15921594 }
1595+ if (power_user.stscript.autocomplete.showInAllMacroFields === undefined) {
1596+ power_user.stscript.autocomplete.showInAllMacroFields = defaultStscript.autocomplete.showInAllMacroFields;
1597+ }
15931598 }
15941599 if (power_user.stscript.parser === undefined) {
15951600 power_user.stscript.parser = defaultStscript.parser;
@@ -1727,6 +1732,7 @@ export async function loadPowerUserSettings(settings, data) {
17271732
17281733 $('#stscript_autocomplete_state').val(power_user.stscript.autocomplete.state).trigger('input');
17291734 $('#stscript_autocomplete_autoHide').prop('checked', power_user.stscript.autocomplete.autoHide ?? false).trigger('input');
1735+ $('#stscript_autocomplete_showInAllMacroFields').prop('checked', power_user.stscript.autocomplete.showInAllMacroFields ?? false).trigger('input');
17301736 $('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy');
17311737 $('#stscript_autocomplete_style').val(power_user.stscript.autocomplete.style ?? 'theme');
17321738 document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete.style);
@@ -4079,6 +4085,11 @@ jQuery(() => {
40794085 saveSettingsDebounced();
40804086 });
40814087
4088+ $('#stscript_autocomplete_showInAllMacroFields').on('input', function () {
4089+ power_user.stscript.autocomplete.showInAllMacroFields = !!$(this).prop('checked');
4090+ saveSettingsDebounced();
4091+ });
4092+
40824093 $('#stscript_matching').on('change', function () {
40834094 const value = $(this).find(':selected').val();
40844095 power_user.stscript.matching = String(value);
public/scripts/slash-commands/SlashCommandParser.js+25 -796
@@ -16,30 +16,14 @@ import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNa
1616import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
1717import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
1818import {
1919 EnhancedMacroAutoCompleteOptionfindUnclosedScopes,
2020 MacroFlagAutoCompleteOptionbuildMacroAutoCompleteResult,
21- MacroClosingTagAutoCompleteOption,
21+} from '../autocomplete/MacroAutoCompleteHelper.js';
22- VariableShorthandAutoCompleteOption,
23- VariableShorthandDefinitions,
24- VariableNameAutoCompleteOption,
25- VariableOperatorAutoCompleteOption,
26- VariableOperatorDefinitions,
27- isValidVariableShorthandName,
28- parseMacroContext,
29- SimpleAutoCompleteOption,
30-} from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
31-import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
32-import { MacroParser } from '../macros/engine/MacroParser.js';
33-import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
3422import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
3523import { SlashCommandDebugController } from './SlashCommandDebugController.js';
3624import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
3725import { SlashCommandBreak } from './SlashCommandBreak.js';
3826import { macros as macroSystemparseMacroContext } from '../macrosautocomplete/macro-systemEnhancedMacroAutoCompleteOption.js';
39-import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
40-import { chat_metadata } from '/script.js';
41-import { extension_settings } from '../extensions.js';
42-import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
4327
4428/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
4529/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -507,250 +491,33 @@ export class SlashCommandParser {
507491 ?? null
508492 ;
509493 if (childClosure !== null) return null;
510- const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);
494+ // Check if cursor is inside a macro
511- if (macro) {
495+ const macroEntry = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);
512- // Calculate cursor position within the macro for argument context
496+ if (macroEntry) {
513- const cursorInMacro = index - macro.start - 2; // -2 for {{
497+ // Build macro info object for shared function
514498 const macroContent = text.slice(macromacroEntry.start + 2, macromacroEntry.end - (text.slice(macromacroEntry.end - 2, macromacroEntry.end) === '}}' ? 2 : 0));
515499 const contextmacro = parseMacroContext(macroContent, cursorInMacro);{
516-
500+ start: macroEntry.start,
517- // Check if cursor is at/after the closing }} - macro syntax is complete
501+ end: macroEntry.end,
518- const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}';
502+ content: macroContent,
519- const isCursorAtClosing = macroEndsBrackets && index >= macro.end - 1;
520-
521- if (isCursorAtClosing) {
522- // Cursor is at the closing }} - check if this is an unclosed scoped macro
523- const textUpToCursor = text.slice(0, index);
524- const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
525-
526- if (unclosedScopes.length > 0) {
527- const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
528- // Check if the current macro IS the unclosed scoped macro
529- if (scopedMacro.startOffset === macro.start) {
530- // Show scoped context - cursor is right at the end of the opening tag
531- const scopedContext = {
532- ...context,
533- currentArgIndex: context.args.length, // Next arg (scoped content)
534- isInScopedContent: true,
535- scopedMacroName: scopedMacro.name,
536503 };
537504
538- const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
505+ // Use the shared macro autocomplete builder
539- if (macroDef) {
506+ const result = await buildMacroAutoCompleteResult(text, index, { macro });
540- const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
507+ if (result) return result;
541- scopedOption.valueProvider = () => '';
542-
543- const result = new AutoCompleteNameResult(
544- scopedMacro.name,
545- macro.start + 2,
546- [scopedOption],
547- false,
548- );
549- return result;
550- }
551- }
552- }
553-
554- // Not a scoped macro, just clear arg highlighting
555- context.currentArgIndex = -1;
556- }
557-
558- // Use the identifier from context (handles whitespace and flags)
559- // Start position must be where the identifier actually begins (after whitespace/flags)
560- // so that the autocomplete range calculation works correctly
561- const identifier = context.identifier;
562- const identifierStartInText = macro.start + 2 + context.identifierStart;
563-
564- // Use enhanced macro autocomplete when experimental engine is enabled
565- // Pass full text up to cursor for unclosed scope detection
566- const textUpToCursor = text.slice(0, index);
567-
568- // Special case for {{if}} condition: use the condition text for matching/replacement
569- const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0;
570- if (isTypingIfCondition) {
571- // Get the typed condition text and calculate its start position
572- const conditionText = context.args[0] || '';
573- // Find where the condition argument starts in the macro text
574- const separatorMatch = macroContent.match(/^.*?if\s*(?:::?)\s*/);
575- const spaceMatch = macroContent.match(/^.*?if\s+/);
576- let conditionStartOffset;
577- if (separatorMatch) {
578- conditionStartOffset = separatorMatch[0].length;
579- } else if (spaceMatch) {
580- conditionStartOffset = spaceMatch[0].length;
581- } else {
582- conditionStartOffset = context.identifierStart + identifier.length;
583- }
584- let conditionStartInText = macro.start + 2 + conditionStartOffset;
585-
586- // Build if-condition options using macroContent for padding calculation
587- const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
588- const options = this.#buildIfConditionOptions(context, allMacros, macroContent);
589-
590- // For variable shorthand in {{if}} condition, adjust identifier and start position
591- // Same fix as for regular variable shorthands - identifier must be just the var name
592- // Also handle ! inversion prefix: !.var or !$var or !macroName
593- const trimmedCondition = conditionText.trim();
594- const hasInversion = trimmedCondition.startsWith('!');
595- // Trim whitespace after ! to handle "! $myvar" syntax
596- const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition;
597- const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
598- let resultIdentifier = conditionText;
599- let resultStart = conditionStartInText;
600-
601- if (isTypingVarShorthand) {
602- // Identifier = just the variable name part (without prefix and without !)
603- resultIdentifier = conditionAfterInversion.slice(1);
604- // Start = after the ! (if any) and the prefix
605- const prefixChar = conditionAfterInversion[0];
606- const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0);
607- resultStart = conditionStartInText + prefixPosInCondition + 1;
608- } else if (hasInversion && conditionAfterInversion.length === 0) {
609- // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match
610- resultIdentifier = '';
611- // Start at end of actual condition text (including any whitespace after !)
612- // This ensures cursor is within the name range for filtering
613- resultStart = conditionStartInText + conditionText.length;
614- } else if (hasInversion && conditionAfterInversion.length > 0) {
615- // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name
616- resultIdentifier = conditionAfterInversion;
617- // Start = after the ! and any whitespace, at the beginning of the macro name
618- const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion);
619- resultStart = conditionStartInText + macroNameStart;
620- }
621-
622- await onboardingExperimentalMacroEngine('{{if}} macro');
623-
624- const result = new AutoCompleteNameResult(
625- resultIdentifier,
626- resultStart,
627- options,
628- false,
629- () => isTypingVarShorthand
630- ? 'Enter a variable name for the condition'
631- : 'Use {{macro}} syntax for dynamic conditions',
632- () => isTypingVarShorthand
633- ? 'Enter a variable name or select from the list'
634- : 'Enter a macro name or {{macro}} for the condition',
635- );
636- return result;
637508 }
638509
639- /** @type {()=>string|undefined} */
510+ // Check if cursor is in scoped content of an unclosed macro (not inside a macro)
640- let makeNoMatchText = undefined;
641- /** @type {()=>string|undefined} */
642- let makeNoOptionsText = undefined;
643-
644- const options = this.#buildEnhancedMacroOptions(context, textUpToCursor);
645-
646- // For variable shorthands, calculate the correct identifier and start position
647- // based on what the user is currently typing (variable name, operator, or value)
648- let resultIdentifier = identifier;
649- let resultStart = identifierStartInText;
650- if (context.isVariableShorthand && context.variablePrefix) {
651- // Find where the prefix is in the macro content
652- const prefixIndex = macroContent.indexOf(context.variablePrefix);
653-
654- if (context.isTypingVariableName) {
655- // Typing variable name: identifier = variableName, start = after prefix
656- resultIdentifier = context.variableName;
657- if (prefixIndex >= 0) {
658- resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
659- }
660- } else if (context.isTypingOperator) {
661- // Typing operator: identifier = partial operator or current operator, start = after variable name
662- resultIdentifier = context.partialOperator || context.variableOperator || '';
663- // Use actual variableNameEnd position from parsing (accounts for whitespace)
664- resultStart = macro.start + 2 + context.variableNameEnd;
665- // Skip whitespace between variable name and operator
666- while (resultStart < index && /\s/.test(text[resultStart])) {
667- resultStart++;
668- }
669- } else if (context.isOperatorComplete) {
670- // Operator complete (++ or --) - show context but no value input needed
671- resultIdentifier = '';
672- resultStart = index; // Cursor at end
673- } else if (context.hasInvalidTrailingChars) {
674- // Invalid chars after variable name: show the invalid chars for warning
675- resultIdentifier = context.invalidTrailingChars || '';
676- // Use actual variableNameEnd position from parsing
677- resultStart = macro.start + 2 + context.variableNameEnd;
678- } else if (context.isTypingValue) {
679- // Typing value: identifier = value being typed, start = after operator
680- resultIdentifier = context.variableValue;
681- // Use actual operatorEnd position from parsing (accounts for whitespace)
682- resultStart = macro.start + 2 + context.variableOperatorEnd;
683- // Skip any whitespace between operator and value
684- while (resultStart < index && /\s/.test(text[resultStart])) {
685- resultStart++;
686- }
687-
688- makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
689- makeNoOptionsText = () => 'Enter a variable value';
690- } else {
691- // Fallback: use variable name
692- resultIdentifier = context.variableName;
693- if (prefixIndex >= 0) {
694- resultStart = macro.start + 2 + prefixIndex + 1;
695- }
696- }
697-
698- if (!makeNoMatchText && !makeNoOptionsText) {
699- makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.';
700- makeNoOptionsText = () => 'Enter a variable name to create or use a new variable';
701- }
702- }
703-
704- const result = new AutoCompleteNameResult(
705- resultIdentifier,
706- resultStart,
707- options,
708- false,
709- makeNoMatchText,
710- makeNoOptionsText,
711- );
712- return result;
713- }
714-
715- // Check if cursor is in scoped content of an unclosed macro
716511 const textUpToCursor = text.slice(0, index);
717512 const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
718513 if (unclosedScopes.length > 0) {
719- const scopedMacro = unclosedScopes[unclosedScopes.length - 1];
514+ // Use the shared macro autocomplete builder for scoped content
720- // Find the original macro in macroIndex to get full info
515+ const result = await buildMacroAutoCompleteResult(text, index, {
721- const originalMacro = this.macroIndex.find(it => it.start === scopedMacro.startOffset);
516+ macro: null,
722- if (originalMacro) {
517+ textUpToCursor,
723- // Parse the original macro content to get base context
518+ unclosedScopes,
724- const macroContent = text.slice(originalMacro.start + 2, originalMacro.end - 2);
519+ });
725- const baseContext = parseMacroContext(macroContent, macroContent.length);
520+ if (result) return result;
726-
727- // Create a scoped context - show next arg as current (the scoped content)
728- const scopedContext = {
729- ...baseContext,
730- currentArgIndex: baseContext.args.length, // Next arg index (the scoped one)
731- isInScopedContent: true,
732- scopedMacroName: scopedMacro.name,
733- };
734-
735- await onboardingExperimentalMacroEngine('scoped macros');
736-
737- // Only show the scoped macro's details - no list of other macros
738- // This creates a "details only" view showing the scoped arg being typed
739- const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
740- if (macroDef) {
741- const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
742- // Mark as non-insertable - we're just showing details
743- scopedOption.valueProvider = () => '';
744-
745- const result = new AutoCompleteNameResult(
746- scopedMacro.name, // Use macro name so it shows as "match"
747- originalMacro.start + 2, // Point to original macro
748- [scopedOption],
749- false,
750- );
751- return result;
752- }
753- }
754521 }
755522 if (executor.name == ':') {
756523 const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
@@ -785,544 +552,6 @@ export class SlashCommandParser {
785552 }
786553
787554 /**
788- * Builds enhanced macro autocomplete options from the MacroRegistry.
789- * When in the flags area (before identifier), includes flag options.
790- * When typing arguments (after ::), prioritizes the exact macro match.
791- * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
792- * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
793- * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
794- */
795- #buildEnhancedMacroOptions(context, textUpToCursor = '') {
796- /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
797- const options = [];
798-
799- // Handle variable shorthand mode
800- if (context.isVariableShorthand) {
801- return this.#buildVariableShorthandOptions(context);
802- }
803-
804- // Check for unclosed scoped macros and suggest closing tags first
805- const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
806- if (unclosedScopes.length > 0) {
807- // Suggest closing the innermost (last) unclosed scope first
808- const innermostScope = unclosedScopes[unclosedScopes.length - 1];
809- // Preserve whitespace padding from the opening tag
810- // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding
811- const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name, {
812- paddingBefore: innermostScope.paddingBefore,
813- paddingAfter: innermostScope.paddingAfter,
814- currentPadding: context.paddingBefore,
815- });
816- options.push(closingOption);
817-
818- // If inside a scoped {{if}}, also suggest {{else}}
819- if (innermostScope.name === 'if') {
820- const macroDef = macroSystem.registry.getPrimaryMacro('else');
821- const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
822- elseOption.sortPriority = 2;
823- options.push(elseOption);
824- }
825- }
826-
827- // If cursor is in the flags area (before identifier starts), include flag options
828- if (context.isInFlagsArea) {
829- // Build flag options with priority-based sorting
830- // Last typed flag has highest priority (1), other flags have lower priority (10)
831- // Already-typed flags (except last) are hidden from the list
832- const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null;
833-
834- // Add last typed flag with high priority (so it appears at top)
835- if (lastTypedFlag) {
836- const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag);
837- if (lastFlagDef) {
838- const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef);
839- // Mark as already typed - valueProvider returns empty so it doesn't re-insert
840- lastFlagOption.valueProvider = () => '';
841- // High priority to appear at top (after closing tags at 1)
842- lastFlagOption.sortPriority = 2;
843- options.push(lastFlagOption);
844- }
845- }
846-
847- // Add flags that haven't been typed yet (skip already-typed ones except last)
848- for (const [symbol, flagDef] of MacroFlagDefinitions) {
849- // Skip the last typed flag (already added above) and other already-typed flags
850- if (context.flags.includes(symbol)) {
851- continue;
852- }
853- const flagOption = new MacroFlagAutoCompleteOption(flagDef);
854-
855- // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant
856- let isSelectable = flagDef.implemented;
857- if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false;
858- if (!isSelectable) {
859- flagOption.valueProvider = () => '';
860- }
861- // Normal flag priority
862- flagOption.sortPriority = isSelectable ? 10 : 12;
863- options.push(flagOption);
864- }
865-
866- // Add variable shorthand prefix options (. for local, $ for global)
867- // These allow users to type variable shorthands instead of macro names
868- for (const [, varShorthandDef] of VariableShorthandDefinitions) {
869- const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef);
870- varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12)
871- options.push(varOption);
872- }
873- }
874-
875- // Get all macros from the registry (excluding hidden aliases)
876- const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
877-
878- // If we're typing arguments (after ::), only show the context to the matching macro
879- const isTypingArgs = context.currentArgIndex >= 0;
880-
881- // Check if we're inside a scoped {{if}} for {{else}} selectability
882- const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
883-
884- for (const macro of allMacros) {
885- // Check if this macro matches the typed identifier
886- const isExactMatch = macro.name === context.identifier;
887- const isAliasMatch = macro.aliasOf === context.identifier;
888-
889- // Only pass context to the macro that matches the identifier being typed
890- // This ensures argument hints only show for the relevant macro
891- /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
892- let macroContext = (isExactMatch || isAliasMatch) ? context : null;
893-
894- // If no context, we pass some options for additional details though
895- if (!macroContext) {
896- macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
897- paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed
898- flags: context.flags,
899- currentFlag: context.currentFlag,
900- fullText: context.fullText,
901- });
902- }
903-
904- const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
905-
906- // {{else}} is only selectable inside a scoped {{if}} block
907- // Outside of {{if}}, it should appear in the list but not be tab-completable
908- if (macro.name === 'else' && !isInsideScopedIf) {
909- option.valueProvider = () => '';
910- option.makeSelectable = false;
911- }
912-
913- // When typing arguments, prioritize exact matches by putting them first
914- if (isTypingArgs && (isExactMatch || isAliasMatch)) {
915- options.unshift(option);
916- } else {
917- options.push(option);
918- }
919- }
920-
921- return options;
922- }
923-
924- /**
925- * Builds autocomplete options for variable shorthand syntax (.varName or $varName).
926- * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
927- * @param {Object} [opts] - Optional configuration.
928- * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
929- * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
930- * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
931- */
932- #buildVariableShorthandOptions(context, opts = {}) {
933- const { forIfCondition = false, paddingAfter = '' } = opts;
934- /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
935- const options = [];
936-
937- const isLocal = context.variablePrefix === '.';
938- const scope = isLocal ? 'local' : 'global';
939-
940- // Always show the typed variable prefix as a non-completable option (like flags do)
941- // This allows the details panel to show information about the prefix
942- const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix);
943- if (prefixDef) {
944- const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
945- prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
946- prefixOption.makeSelectable = false;
947- prefixOption.sortPriority = 1; // Show at top
948- prefixOption.matchProvider = () => true; // Always show regardless of filtering
949- options.push(prefixOption);
950- }
951-
952- // If typing the variable name, suggest existing variables
953- // Get existing variable names from the appropriate scope
954- // Filter to only include names that are valid for shorthand syntax
955- const existingVariables = this.#getVariableNames(scope)
956- .filter(name => isValidVariableShorthandName(name));
957-
958- // Check if the typed variable name exactly matches an existing variable
959- const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName);
960-
961- if (context.isTypingVariableName) {
962- // Add existing variables that match the typed name
963- for (const varName of existingVariables) {
964- const option = new VariableNameAutoCompleteOption(varName, scope, false);
965- // For {{if}} condition, provide full value with closing braces
966- if (forIfCondition) {
967- option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already.
968- option.makeSelectable = true;
969- }
970- // Variables matching the typed prefix get higher priority
971- if (varName.startsWith(context.variableName)) {
972- option.sortPriority = 3;
973- } else {
974- option.sortPriority = 10;
975- }
976- options.push(option);
977- }
978-
979- // If typing a name that doesn't exist, offer to create a new variable
980- // But if the name is invalid for shorthand syntax, show a warning instead
981- if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) {
982- const isInvalid = !isValidVariableShorthandName(context.variableName);
983- const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid);
984- newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning
985- if (isInvalid) {
986- // Make it non-selectable since it can't be used
987- newVarOption.valueProvider = () => '';
988- newVarOption.makeSelectable = false;
989- } else if (forIfCondition) {
990- // For {{if}} condition, provide full value with closing braces
991- newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`;
992- }
993- options.push(newVarOption);
994- }
995-
996- // If the typed variable name exactly matches an existing variable, also show operators
997- // This allows users to see available operators without having to type a space first
998- if (variableNameMatchesExisting) {
999- for (const [, operatorDef] of VariableOperatorDefinitions) {
1000- const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
1001- opOption.sortPriority = 6; // Lower priority than variable suggestions
1002- opOption.matchProvider = () => true; // Always show
1003- // IMPORTANT: Operators should INSERT after variable name, not replace it
1004- // Use replacementStartOffset to shift insertion point past the variable name
1005- opOption.replacementStartOffset = context.variableName.length;
1006- options.push(opOption);
1007- }
1008- }
1009- }
1010-
1011- // If there are invalid trailing characters after the variable name, show a warning
1012- if (context.hasInvalidTrailingChars) {
1013- // Show the full invalid name (variableName + invalidTrailingChars) with a warning
1014- const fullInvalidName = context.variableName + (context.invalidTrailingChars || '');
1015- const invalidOption = new VariableNameAutoCompleteOption(
1016- fullInvalidName,
1017- scope,
1018- false,
1019- true, // isInvalidName - triggers warning display
1020- );
1021- invalidOption.valueProvider = () => ''; // Don't insert anything
1022- invalidOption.makeSelectable = false;
1023- invalidOption.sortPriority = 2;
1024- invalidOption.matchProvider = () => true; // Always show
1025- options.push(invalidOption);
1026- // Return early - don't show operators when syntax is invalid
1027- return options;
1028- }
1029-
1030- // If ready for operator (after variable name), suggest operators
1031- if (context.isTypingOperator) {
1032- // Show the current variable name as context (already typed)
1033- const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1034- varNameOption.valueProvider = () => ''; // Already typed, don't re-insert
1035- varNameOption.sortPriority = 2;
1036- varNameOption.matchProvider = () => true; // Always show
1037- options.push(varNameOption);
1038-
1039- // Then show available operators, filtered by partial prefix if any
1040- // Also filter by current complete operator to show longer variants (e.g., > shows >=)
1041- const partialOp = context.partialOperator || '';
1042- const currentOp = context.variableOperator || '';
1043- const filterPrefix = partialOp || currentOp;
1044- for (const [, operatorDef] of VariableOperatorDefinitions) {
1045- // Filter by operator prefix if user is typing one
1046- // This allows typing ">" to show both ">" and ">="
1047- if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) {
1048- continue;
1049- }
1050- const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
1051- // Exact match gets higher priority
1052- opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5;
1053- // Already-typed operator is non-selectable
1054- if (operatorDef.symbol === currentOp) {
1055- opOption.valueProvider = () => '';
1056- }
1057- // Always match operators when showing operator suggestions
1058- opOption.matchProvider = () => true;
1059- options.push(opOption);
1060- }
1061- }
1062-
1063- // If typing value (after = or +=), no autocomplete needed - freeform text
1064- // But we show the current context for reference (greyed out, non-selectable)
1065- if (context.isTypingValue && !context.isTypingOperator) {
1066- // Show the current variable name as context (non-selectable)
1067- const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1068- varNameOption.valueProvider = () => ''; // Context only
1069- varNameOption.makeSelectable = false;
1070- varNameOption.sortPriority = 2;
1071- varNameOption.matchProvider = () => true; // Always show
1072- options.push(varNameOption);
1073-
1074- // Show the operator that was used (non-selectable)
1075- if (context.variableOperator) {
1076- const opDef = VariableOperatorDefinitions.get(context.variableOperator);
1077- if (opDef) {
1078- const opOption = new VariableOperatorAutoCompleteOption(opDef);
1079- opOption.valueProvider = () => ''; // Already typed
1080- opOption.makeSelectable = false;
1081- opOption.sortPriority = 3;
1082- opOption.matchProvider = () => true; // Always show
1083- options.push(opOption);
1084- }
1085- }
1086- }
1087-
1088- // If operator is complete (++ or --), show context without value input (non-selectable)
1089- if (context.isOperatorComplete && !context.isTypingOperator) {
1090- // Show the current variable name as context (non-selectable)
1091- const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1092- varNameOption.valueProvider = () => ''; // Context only
1093- varNameOption.makeSelectable = false;
1094- varNameOption.sortPriority = 2;
1095- varNameOption.matchProvider = () => true; // Always show
1096- options.push(varNameOption);
1097-
1098- // Show the operator that was used (non-selectable)
1099- if (context.variableOperator) {
1100- const opDef = VariableOperatorDefinitions.get(context.variableOperator);
1101- if (opDef) {
1102- const opOption = new VariableOperatorAutoCompleteOption(opDef);
1103- opOption.valueProvider = () => ''; // Already typed
1104- opOption.makeSelectable = false;
1105- opOption.sortPriority = 3;
1106- opOption.matchProvider = () => true; // Always show
1107- options.push(opOption);
1108- }
1109- }
1110- }
1111-
1112- return options;
1113- }
1114-
1115- /**
1116- * Gets variable names from the specified scope.
1117- * @param {'local'|'global'} scope - The variable scope.
1118- * @returns {string[]} Array of variable names.
1119- */
1120- #getVariableNames(scope) {
1121- try {
1122- // Import chat_metadata and extension_settings dynamically to avoid circular deps
1123- // These are the same sources used by commonEnumProviders.variables
1124- if (scope === 'local') {
1125- // Local variables are in chat_metadata.variables
1126- return Object.keys(chat_metadata?.variables ?? {});
1127- } else {
1128- // Global variables are in extension_settings.variables.global
1129- return Object.keys(extension_settings?.variables?.global ?? {});
1130- }
1131- } catch {
1132- return [];
1133- }
1134- }
1135-
1136- /**
1137- * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
1138- * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
1139- * @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros
1140- * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers").
1141- * @returns {AutoCompleteOption[]}
1142- */
1143- #buildIfConditionOptions(context, allMacros, macroInnerText) {
1144- /** @type {AutoCompleteOption[]} */
1145- const options = [];
1146-
1147- // Calculate padding from the original macro text for matching whitespace on completion
1148- // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}')
1149- const leadingMatch = macroInnerText.match(/^(\s*)/);
1150- const paddingAfter = leadingMatch ? leadingMatch[1] : '';
1151-
1152- // Get the condition text being typed (trimmed for detection)
1153- const conditionText = (context.args[0] || '').trim();
1154-
1155- // Check for inversion prefix (!) - also trim whitespace after !
1156- const hasInversionPrefix = conditionText.startsWith('!');
1157- const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText;
1158-
1159- const inversionOption = new SimpleAutoCompleteOption({
1160- name: '!',
1161- symbol: '🔁',
1162- description: 'Invert condition (NOT)',
1163- detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.',
1164- type: 'inverse',
1165- });
1166-
1167- // Check if condition starts with a variable shorthand prefix (with or without !)
1168- const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
1169-
1170- if (isTypingVariableShorthand) {
1171- // User is typing a variable shorthand - reuse #buildVariableShorthandOptions
1172- const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]);
1173- const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix
1174-
1175- // If inverted, show the ! as non-selectable context
1176- if (hasInversionPrefix) {
1177- inversionOption.valueProvider = () => ''; // Already typed
1178- inversionOption.makeSelectable = false;
1179- inversionOption.sortPriority = 0;
1180- options.push(inversionOption);
1181- }
1182-
1183- // Create a synthetic context for #buildVariableShorthandOptions
1184- /** @type {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} */
1185- const varContext = {
1186- ...context,
1187- isVariableShorthand: true,
1188- variablePrefix: prefix,
1189- variableName: varNameTyped,
1190- isTypingVariableName: true,
1191- isTypingOperator: false,
1192- isTypingValue: false,
1193- isOperatorComplete: false,
1194- hasInvalidTrailingChars: false,
1195- variableOperator: null,
1196- variableValue: '',
1197- };
1198-
1199- const varOptions = this.#buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter });
1200- options.push(...varOptions);
1201- return options;
1202- }
1203-
1204- // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion
1205-
1206- // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed
1207- if (conditionText.length === 0) {
1208- // Nothing typed - offer ! as selectable option
1209- inversionOption.valueProvider = () => '!';
1210- inversionOption.makeSelectable = true;
1211- inversionOption.sortPriority = -1; // Show at very top
1212- options.push(inversionOption);
1213- } else if (hasInversionPrefix && conditionAfterInversion.length === 0) {
1214- // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes
1215- inversionOption.valueProvider = () => ''; // Already typed
1216- inversionOption.makeSelectable = false;
1217- inversionOption.sortPriority = -1;
1218- options.push(inversionOption);
1219- }
1220-
1221- // Add variable shorthand prefix options when no content typed yet (or just ! typed)
1222- if (conditionAfterInversion.length === 0) {
1223- for (const [, prefixDef] of VariableShorthandDefinitions) {
1224- const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
1225- // Complete with just the prefix symbol
1226- prefixOption.valueProvider = () => prefixDef.type;
1227- prefixOption.makeSelectable = true;
1228- prefixOption.sortPriority = 0; // Show at top
1229- options.push(prefixOption);
1230- }
1231- }
1232-
1233- // Add zero-arg macros as condition shorthand options
1234- for (const macro of allMacros) {
1235- // Only include macros that require zero arguments (can be auto-resolved)
1236- if (macro.minArgs !== 0) continue;
1237-
1238- // Skip internal/utility macros that don't make sense as conditions
1239- if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue;
1240-
1241- const option = new EnhancedMacroAutoCompleteOption(macro, {
1242- noBraces: true,
1243- paddingAfter,
1244- closeWithBraces: true,
1245- });
1246- options.push(option);
1247- }
1248-
1249- return options;
1250- }
1251-
1252- /**
1253- * Finds unclosed scoped macros in the text up to cursor position.
1254- * Uses the MacroParser and MacroCstWalker for accurate analysis.
1255- *
1256- * @param {string} textUpToCursor - The document text up to the cursor position.
1257- * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
1258- */
1259- #findUnclosedScopes(textUpToCursor) {
1260- if (!textUpToCursor) return [];
1261-
1262- try {
1263- // Parse the document to get the CST
1264- const { cst } = MacroParser.parseDocument(textUpToCursor);
1265- if (!cst) return [];
1266-
1267- // Use the CST walker to find unclosed scopes
1268- return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst });
1269- } catch {
1270- // If parsing fails (incomplete input), fall back to simple regex approach
1271- return this.#findUnclosedScopesRegex(textUpToCursor);
1272- }
1273- }
1274-
1275- /**
1276- * Fallback regex-based approach for finding unclosed scopes.
1277- * Used when the parser fails on incomplete input.
1278- *
1279- * @param {string} text - The text to analyze.
1280- * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
1281- */
1282- #findUnclosedScopesRegex(text) {
1283- // Regex to find macro openings and closings, capturing whitespace padding
1284- // Group 1: padding after {{, Group 2: optional /, Group 3: macro name
1285- const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g;
1286- const stack = [];
1287-
1288- let match;
1289- while ((match = macroPattern.exec(text)) !== null) {
1290- const paddingBefore = match[1];
1291- const isClosing = match[2] === '/';
1292- const name = match[3];
1293-
1294- if (isClosing) {
1295- // Pop matching opener (case-insensitive)
1296- if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) {
1297- stack.pop();
1298- }
1299- } else {
1300- // Check if macro can accept scoped content
1301- const macroDef = macroSystem.registry.getPrimaryMacro(name);
1302- if (macroDef && macroDef.maxArgs > 0) {
1303- // Try to find closing }} to extract trailing whitespace
1304- let paddingAfter = '';
1305- const afterMatch = text.slice(match.index + match[0].length);
1306- const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/);
1307- if (closingMatch) {
1308- paddingAfter = closingMatch[1];
1309- }
1310-
1311- stack.push({
1312- name,
1313- startOffset: match.index,
1314- endOffset: match.index + match[0].length,
1315- paddingBefore,
1316- paddingAfter,
1317- });
1318- }
1319- }
1320- }
1321-
1322- return stack;
1323- }
1324-
1325- /**
1326555 * Moves the index <length> number of characters forward and returns the last character taken.
1327556 * @param {number} length Number of characters to take.
1328557 * @param {boolean} keep Whether to add the characters to the kept text.
public/scripts/world-info.js+2 -0
@@ -2844,6 +2844,7 @@ function enableKeysInputHelper({ template, entry, entryPropName, originalDataVal
28442844 const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;
28452845 const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
28462846 input.data('uid', entry.uid);
2847+ input[0].dataset.macros = ''; // active
28472848 input.on('click', function (event) {
28482849 event.stopPropagation();
28492850 });
@@ -3579,6 +3580,7 @@ export async function getWorldEntry(name, data, entry) {
35793580 const contentInput = editTemplate.find('textarea[name="content"]');
35803581 contentInput.data('uid', entry.uid);
35813582 contentInput.attr('id', contentInputId);
3583+ contentInput[0].dataset.macros = ''; // active
35823584 contentInput.on('input', async function (_, { skipCount, noSave } = {}) {
35833585 const uid = $(this).data('uid');
35843586 const value = $(this).val();
public/style.css+39 -0
@@ -1654,6 +1654,13 @@ button {
16541654 width: 50vw;
16551655 }
16561656
1657+ /* Small style variant for macro autocomplete (33vw, max 700px) */
1658+ &[data-macros-autocomplete-style="small"] .autoComplete {
1659+ width: 33vw;
1660+ max-width: 700px;
1661+ min-width: 250px;
1662+ }
1663+
16571664 &:after {
16581665 content: "";
16591666 flex: 1 1 0;
@@ -1706,6 +1713,38 @@ button {
17061713 width: 25vw;
17071714 }
17081715
1716+ /* Small style variant for macro autocomplete (33vw, max 700px) */
1717+ &[data-macros-autocomplete-style="small"] .autoComplete-details {
1718+ width: 33vw;
1719+ max-width: 700px;
1720+ min-width: 250px;
1721+ }
1722+
1723+ &[data-macros-autocomplete-style="small"] {
1724+ &.left {
1725+ &:before {
1726+ flex: 0 1 calc(var(--targetOffset) * 1px - 33vw);
1727+ }
1728+
1729+ &:after {
1730+ flex: 1 0 auto;
1731+ max-width: 33vw;
1732+ }
1733+ }
1734+
1735+ &.right {
1736+ &:before {
1737+ flex: 0 0 calc(var(--targetOffset) * 1px + 33vw);
1738+ }
1739+ }
1740+
1741+ &.full {
1742+ .autoComplete-details {
1743+ max-width: 33vw;
1744+ }
1745+ }
1746+ }
1747+
17091748 &.left {
17101749 &:before {
17111750 flex: 0 1 calc(var(--targetOffset) * 1px - 25vw);