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 @@
4006 <small data-i18n="Story String">Story String</small>4006 <small data-i18n="Story String">Story String</small>
4007 <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>4007 <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>
4008 </label>4008 </label>
4009 <textarea id="context_story_string" class="text_pole textarea_compact autoSetHeight"></textarea>4009 <textarea id="context_story_string" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4010 </div>4010 </div>
4011 <div class="flex-container flexFlowColumn" data-cc-null>4011 <div class="flex-container flexFlowColumn" data-cc-null>
4012 <div id="context_story_string_position_block">4012 <div id="context_story_string_position_block">
@@ -4043,7 +4043,7 @@
4043 <small data-i18n="Example Separator">Example Separator</small>4043 <small data-i18n="Example Separator">Example Separator</small>
4044 </label>4044 </label>
4045 <div>4045 <div>
4046 <textarea id="context_example_separator" class="text_pole textarea_compact autoSetHeight"></textarea>4046 <textarea id="context_example_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4047 </div>4047 </div>
4048 </div>4048 </div>
4049 <div class="flex1">4049 <div class="flex1">
@@ -4051,7 +4051,7 @@
4051 <small data-i18n="Chat Start">Chat Start</small>4051 <small data-i18n="Chat Start">Chat Start</small>
4052 </label>4052 </label>
4053 <div>4053 <div>
4054 <textarea id="context_chat_start" class="text_pole textarea_compact autoSetHeight"></textarea>4054 <textarea id="context_chat_start" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4055 </div>4055 </div>
4056 </div>4056 </div>
4057 </div>4057 </div>
@@ -4215,11 +4215,11 @@
4215 <div class="flex-container">4215 <div class="flex-container">
4216 <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.">4216 <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.">
4217 <small data-i18n="User Prefix">User Message Prefix</small>4217 <small data-i18n="User Prefix">User Message Prefix</small>
4218 <textarea id="instruct_input_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>4218 <textarea id="instruct_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4219 </div>4219 </div>
4220 <div class="flexAuto" title="Inserted after a User message." data-i18n="[title]Inserted after a User message.">4220 <div class="flexAuto" title="Inserted after a User message." data-i18n="[title]Inserted after a User message.">
4221 <small data-i18n="User Suffix">User Message Suffix</small>4221 <small data-i18n="User Suffix">User Message Suffix</small>
4222 <textarea id="instruct_input_suffix" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>4222 <textarea id="instruct_input_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
4223 </div>4223 </div>
4224 </div>4224 </div>
4225 </details>4225 </details>
@@ -4228,11 +4228,11 @@
4228 <div class="flex-container">4228 <div class="flex-container">
4229 <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.">4229 <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.">
4230 <small data-i18n="Assistant Prefix">Assistant Message Prefix</small>4230 <small data-i18n="Assistant Prefix">Assistant Message Prefix</small>
4231 <textarea id="instruct_output_sequence" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>4231 <textarea id="instruct_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
4232 </div>4232 </div>
4233 <div class="flexAuto" title="Inserted after an Assistant message." data-i18n="[title]Inserted after an Assistant message.">4233 <div class="flexAuto" title="Inserted after an Assistant message." data-i18n="[title]Inserted after an Assistant message.">
4234 <small data-i18n="Assistant Suffix">Assistant Message Suffix</small>4234 <small data-i18n="Assistant Suffix">Assistant Message Suffix</small>
4235 <textarea id="instruct_output_suffix" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>4235 <textarea id="instruct_output_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
4236 </div>4236 </div>
4237 </div>4237 </div>
4238 </details>4238 </details>
@@ -4241,11 +4241,11 @@
4241 <div class="flex-container">4241 <div class="flex-container">
4242 <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.">4242 <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.">
4243 <small data-i18n="System Prefix">System Message Prefix</small>4243 <small data-i18n="System Prefix">System Message Prefix</small>
4244 <textarea id="instruct_system_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>4244 <textarea id="instruct_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4245 </div>4245 </div>
4246 <div class="flexAuto" id="instruct_system_suffix_block" title="Inserted after a System message." data-i18n="[title]Inserted after a System message.">4246 <div class="flexAuto" id="instruct_system_suffix_block" title="Inserted after a System message." data-i18n="[title]Inserted after a System message.">
4247 <small data-i18n="System Suffix">System Message Suffix</small>4247 <small data-i18n="System Suffix">System Message Suffix</small>
4248 <textarea id="instruct_system_suffix" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>4248 <textarea id="instruct_system_suffix" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
4249 </div>4249 </div>
4250 </div>4250 </div>
4251 <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.">4251 <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 @@
4260 <div class="flex-container">4260 <div class="flex-container">
4261 <div class="flexAuto" title="Inserted before the first Assistant's message." data-i18n="[title]Inserted before the first Assistant's message.">4261 <div class="flexAuto" title="Inserted before the first Assistant's message." data-i18n="[title]Inserted before the first Assistant's message.">
4262 <small data-i18n="First Assistant Prefix">First Assistant Prefix</small>4262 <small data-i18n="First Assistant Prefix">First Assistant Prefix</small>
4263 <textarea id="instruct_first_output_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>4263 <textarea id="instruct_first_output_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4264 </div>4264 </div>
4265 <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">4265 <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">
4266 <small data-i18n="Last Assistant Prefix">Last Assistant Prefix</small>4266 <small data-i18n="Last Assistant Prefix">Last Assistant Prefix</small>
4267 <textarea id="instruct_last_output_sequence" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>4267 <textarea id="instruct_last_output_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
4268 </div>4268 </div>
4269 </div>4269 </div>
4270 <div class="flex-container">4270 <div class="flex-container">
4271 <div class="flexAuto" title="Inserted before the first User's message." data-i18n="[title]Inserted before the first User's message.">4271 <div class="flexAuto" title="Inserted before the first User's message." data-i18n="[title]Inserted before the first User's message.">
4272 <small data-i18n="First User Prefix">First User Prefix</small>4272 <small data-i18n="First User Prefix">First User Prefix</small>
4273 <textarea id="instruct_first_input_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>4273 <textarea id="instruct_first_input_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4274 </div>4274 </div>
4275 <div class="flexAuto" title="Inserted before the last User's message." data-i18n="[title]instruct_last_input_sequence">4275 <div class="flexAuto" title="Inserted before the last User's message." data-i18n="[title]instruct_last_input_sequence">
4276 <small data-i18n="Last User Prefix">Last User Prefix</small>4276 <small data-i18n="Last User Prefix">Last User Prefix</small>
4277 <textarea id="instruct_last_input_sequence" class="text_pole wide100p textarea_compact autoSetHeight"></textarea>4277 <textarea id="instruct_last_input_sequence" data-macros class="text_pole wide100p textarea_compact autoSetHeight"></textarea>
4278 </div>4278 </div>
4279 </div>4279 </div>
4280 <div class="flex-container">4280 <div class="flex-container">
4281 <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.">4281 <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.">
4282 <small data-i18n="System Instruction Prefix">System Instruction Prefix</small>4282 <small data-i18n="System Instruction Prefix">System Instruction Prefix</small>
4283 <textarea id="instruct_last_system_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>4283 <textarea id="instruct_last_system_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4284 </div>4284 </div>
4285 <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).">4285 <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).">
4286 <small data-i18n="Stop Sequence">Stop Sequence</small>4286 <small data-i18n="Stop Sequence">Stop Sequence</small>
4287 <textarea id="instruct_stop_sequence" class="text_pole textarea_compact autoSetHeight"></textarea>4287 <textarea id="instruct_stop_sequence" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4288 </div>4288 </div>
4289 </div>4289 </div>
4290 <div class="flex-container">4290 <div class="flex-container">
4291 <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.">4291 <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.">
4292 <small data-i18n="User Filler Message">User Filler Message</small>4292 <small data-i18n="User Filler Message">User Filler Message</small>
4293 <textarea id="instruct_user_alignment_message" class="text_pole textarea_compact autoSetHeight"></textarea>4293 <textarea id="instruct_user_alignment_message" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4294 </div>4294 </div>
4295 </div>4295 </div>
4296 </details>4296 </details>
@@ -4328,7 +4328,7 @@
4328 <small data-i18n="Prompt Content">Prompt Content</small>4328 <small data-i18n="Prompt Content">Prompt Content</small>
4329 <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>4329 <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>
4330 </label>4330 </label>
4331 <textarea id="sysprompt_content" class="text_pole textarea_compact autoSetHeight"></textarea>4331 <textarea id="sysprompt_content" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4332 </div>4332 </div>
43334333
4334 <div>4334 <div>
@@ -4336,7 +4336,7 @@
4336 <small data-i18n="Post-History Instructions">Post-History Instructions</small>4336 <small data-i18n="Post-History Instructions">Post-History Instructions</small>
4337 <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>4337 <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>
4338 </label>4338 </label>
4339 <textarea id="sysprompt_post_history" class="text_pole textarea_compact autoSetHeight"></textarea>4339 <textarea id="sysprompt_post_history" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4340 </div>4340 </div>
4341 </div>4341 </div>
43424342
@@ -4461,17 +4461,17 @@
4461 <div class="flex-container">4461 <div class="flex-container">
4462 <div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix">4462 <div class="flex1" title="Inserted before the reasoning content." data-i18n="[title]reasoning_prefix">
4463 <small data-i18n="Prefix">Prefix</small>4463 <small data-i18n="Prefix">Prefix</small>
4464 <textarea id="reasoning_prefix" class="text_pole textarea_compact autoSetHeight"></textarea>4464 <textarea id="reasoning_prefix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4465 </div>4465 </div>
4466 <div class="flex1" title="Inserted after the reasoning content." data-i18n="[title]reasoning_suffix">4466 <div class="flex1" title="Inserted after the reasoning content." data-i18n="[title]reasoning_suffix">
4467 <small data-i18n="Suffix">Suffix</small>4467 <small data-i18n="Suffix">Suffix</small>
4468 <textarea id="reasoning_suffix" class="text_pole textarea_compact autoSetHeight"></textarea>4468 <textarea id="reasoning_suffix" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4469 </div>4469 </div>
4470 </div>4470 </div>
4471 <div class="flex-container">4471 <div class="flex-container">
4472 <div class="flex1" title="Inserted between the reasoning and the message content." data-i18n="[title]reasoning_separator">4472 <div class="flex1" title="Inserted between the reasoning and the message content." data-i18n="[title]reasoning_separator">
4473 <small data-i18n="Separator">Separator</small>4473 <small data-i18n="Separator">Separator</small>
4474 <textarea id="reasoning_separator" class="text_pole textarea_compact autoSetHeight"></textarea>4474 <textarea id="reasoning_separator" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4475 </div>4475 </div>
4476 </div>4476 </div>
4477 </details>4477 </details>
@@ -4493,7 +4493,7 @@
4493 </span>4493 </span>
4494 </small>4494 </small>
4495 <div>4495 <div>
4496 <input id="markdown_escape_strings" class="text_pole textarea_compact" type="text" data-i18n="[placeholder]comma delimited,no spaces between" placeholder="comma delimited,no spaces between" />4496 <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" />
4497 </div>4497 </div>
4498 </div>4498 </div>
44994499
@@ -4505,7 +4505,7 @@
4505 </span>4505 </span>
4506 </small>4506 </small>
4507 <div>4507 <div>
4508 <textarea id="start_reply_with" class="text_pole textarea_compact autoSetHeight"></textarea>4508 <textarea id="start_reply_with" data-macros class="text_pole textarea_compact autoSetHeight"></textarea>
4509 </div>4509 </div>
4510 <label class="checkbox_label" for="chat-show-reply-prefix-checkbox">4510 <label class="checkbox_label" for="chat-show-reply-prefix-checkbox">
4511 <input id="chat-show-reply-prefix-checkbox" type="checkbox" />4511 <input id="chat-show-reply-prefix-checkbox" type="checkbox" />
@@ -5386,6 +5386,7 @@
5386 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>5386 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
5387 </div>5387 </div>
5388 <div class="inline-drawer-content">5388 <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.">
5389 <label for="stscript_autocomplete_state">5390 <label for="stscript_autocomplete_state">
5390 <small data-i18n="Visibility">Visibility</small>5391 <small data-i18n="Visibility">Visibility</small>
5391 </label>5392 </label>
@@ -5394,12 +5395,19 @@
5394 <option value="1" data-i18n="Input length > 1">Input length > 1</option>5395 <option value="1" data-i18n="Input length > 1">Input length > 1</option>
5395 <option value="2" data-i18n="Always show">Always show</option>5396 <option value="2" data-i18n="Always show">Always show</option>
5396 </select>5397 </select>
5398 </div>
5397 <label class="checkbox_label" for="stscript_autocomplete_autoHide">5399 <label class="checkbox_label" for="stscript_autocomplete_autoHide">
5398 <input id="stscript_autocomplete_autoHide" type="checkbox" />5400 <input id="stscript_autocomplete_autoHide" type="checkbox" />
5399 <small data-i18n="Automatically hide details">5401 <small data-i18n="Automatically hide details">
5400 Automatically hide details5402 Automatically hide details
5401 </small>5403 </small>
5402 </label>5404 </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>
5403 <div class="flex-container">5411 <div class="flex-container">
5404 <div class="flex1" title="Determines how entries are found for autocomplete." data-i18n="[title]Determines how entries are found for autocomplete.">5412 <div class="flex1" title="Determines how entries are found for autocomplete." data-i18n="[title]Determines how entries are found for autocomplete.">
5405 <label for="stscript_matching">5413 <label for="stscript_matching">
@@ -5719,7 +5727,7 @@
5719 <span data-i18n="Persona Description">Persona Description</span>5727 <span data-i18n="Persona Description">Persona Description</span>
5720 <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>5728 <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>
5721 </h4>5729 </h4>
5722 <textarea id="persona_description" name="persona_description" 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>5730 <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
5724 <div class="flex-container justifySpaceBetween">5732 <div class="flex-container justifySpaceBetween">
5725 <h4 data-i18n="Position">Position</h4>5733 <h4 data-i18n="Position">Position</h4>
@@ -5978,7 +5986,7 @@
5978 </span>5986 </span>
5979 </div>5987 </div>
5980 </div>5988 </div>
5981 <textarea id="description_textarea" class="mdHotkeys" 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>5989 <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>
5982 <div class="extension_token_counter">5990 <div class="extension_token_counter">
5983 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span>5991 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="description_textarea" data-token-permanent="true">counting...</span>
5984 </div>5992 </div>
@@ -5998,7 +6006,7 @@
5998 </span>6006 </span>
5999 </div>6007 </div>
6000 </div>6008 </div>
6001 <textarea class="mdHotkeys" id="firstmessage_textarea" 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>6009 <textarea id="firstmessage_textarea" class="mdHotkeys" 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>
6002 <div class="extension_token_counter">6010 <div class="extension_token_counter">
6003 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span>6011 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="firstmessage_textarea">counting...</span>
6004 </div>6012 </div>
@@ -6322,7 +6330,7 @@
6322 <span data-i18n="Main Prompt">Main Prompt</span>6330 <span data-i18n="Main Prompt">Main Prompt</span>
6323 <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>6331 <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>
6324 </h4>6332 </h4>
6325 <textarea id="system_prompt_textarea" name="system_prompt" 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>6333 <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>
6326 <div class="extension_token_counter">6334 <div class="extension_token_counter">
6327 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="system_prompt_textarea">counting...</span>6335 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="system_prompt_textarea">counting...</span>
6328 </div>6336 </div>
@@ -6332,7 +6340,7 @@
6332 <span data-i18n="Post-History Instructions">Post-History Instructions</span>6340 <span data-i18n="Post-History Instructions">Post-History Instructions</span>
6333 <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>6341 <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>
6334 </h4>6342 </h4>
6335 <textarea id="post_history_instructions_textarea" name="post_history_instructions" 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>6343 <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>
6336 <div class="extension_token_counter">6344 <div class="extension_token_counter">
6337 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="post_history_instructions_textarea">counting...</span>6345 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="post_history_instructions_textarea">counting...</span>
6338 </div>6346 </div>
@@ -6385,7 +6393,7 @@
6385 <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>6393 <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>
6386 <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>6394 <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>
6387 </h4>6395 </h4>
6388 <textarea id="personality_textarea" name="personality" 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>6396 <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>
6389 <div class="extension_token_counter">6397 <div class="extension_token_counter">
6390 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="personality_textarea" data-token-permanent="true">counting...</span>6398 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="personality_textarea" data-token-permanent="true">counting...</span>
6391 </div>6399 </div>
@@ -6398,7 +6406,7 @@
6398 <span class="fa-solid fa-circle-question note-link-span"></span>6406 <span class="fa-solid fa-circle-question note-link-span"></span>
6399 </a>6407 </a>
6400 </h4>6408 </h4>
6401 <textarea id="scenario_pole" name="scenario" 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>6409 <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>
6402 <div class="extension_token_counter">6410 <div class="extension_token_counter">
6403 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="scenario_pole" data-token-permanent="true">counting...</span>6411 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="scenario_pole" data-token-permanent="true">counting...</span>
6404 </div>6412 </div>
@@ -6411,7 +6419,7 @@
6411 </span>6419 </span>
6412 <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>6420 <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>
6413 </h4>6421 </h4>
6414 <textarea id="depth_prompt_prompt" name="depth_prompt_prompt" 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>6422 <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>
6415 </div>6423 </div>
6416 <div>6424 <div>
6417 <h4>6425 <h4>
@@ -6461,7 +6469,7 @@
6461 </a>6469 </a>
6462 </h5>6470 </h5>
6463 </div>6471 </div>
6464 <textarea id="mes_example_textarea" class="flexGrow mdHotkeys" name="mes_example" 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>6472 <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>
6465 <div class="extension_token_counter">6473 <div class="extension_token_counter">
6466 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span>6474 <span data-i18n="extension_token_counter">Tokens:</span> <span data-token-counter="mes_example_textarea">counting...</span>
6467 </div>6475 </div>
@@ -7154,7 +7162,7 @@
7154 <span>&nbsp;</span>7162 <span>&nbsp;</span>
7155 <span id="completion_prompt_manager_popup_entry_source"></span>7163 <span id="completion_prompt_manager_popup_entry_source"></span>
7156 </div>7164 </div>
7157 <textarea id="completion_prompt_manager_popup_entry_form_prompt" class="text_pole" name="prompt" placeholder="The prompt to be sent." data-i18n="[placeholder]The prompt to be sent."></textarea>7165 <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>
7158 </div>7166 </div>
7159 <div class="completion_prompt_manager_popup_entry_form_footer">7167 <div class="completion_prompt_manager_popup_entry_form_footer">
7160 <a id="completion_prompt_manager_popup_entry_form_close" title="Close" data-i18n="[title]close" class="fa-solid fa-close menu_button"></a>7168 <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 @@
7530 <b data-i18n="Unique to this chat">Unique to this chat</b>.<br>7538 <b data-i18n="Unique to this chat">Unique to this chat</b>.<br>
7531 <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>7539 <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>
7532 </small>7540 </small>
7533 <textarea id="extension_floating_prompt" class="text_pole textarea_compact" rows="8"></textarea>7541 <textarea id="extension_floating_prompt" data-macros class="text_pole textarea_compact" rows="8"></textarea>
7534 <div class="extension_token_counter">7542 <div class="extension_token_counter">
7535 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_prompt_token_counter">0</span>7543 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_prompt_token_counter">0</span>
7536 </div>7544 </div>
@@ -7587,7 +7595,7 @@
7587 <div class="inline-drawer-content">7595 <div class="inline-drawer-content">
7588 <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, but7596 <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
7589 can't be modified when a group chat is open.</small>7597 can't be modified when a group chat is open.</small>
7590 <textarea id="extension_floating_chara" class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>7598 <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>
7591 <div class="extension_token_counter">7599 <div class="extension_token_counter">
7592 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_chara_token_counter">0</span>7600 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_chara_token_counter">0</span>
7593 </div>7601 </div>
@@ -7619,7 +7627,7 @@
7619 </div>7627 </div>
7620 <div class="inline-drawer-content">7628 <div class="inline-drawer-content">
7621 <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>7629 <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>
7622 <textarea id="extension_floating_default" class="text_pole textarea_compact" rows="8" placeholder="Example:&#10;[Scenario: wacky adventures; Genre: romantic comedy; Style: verbose, creative]"></textarea>7630 <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>
7623 <div class="extension_token_counter">7631 <div class="extension_token_counter">
7624 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_default_token_counter">0</span>7632 <span data-i18n="extension_token_counter">Tokens:</span> <span id="extension_floating_default_token_counter">0</span>
7625 </div>7633 </div>
public/script.js+3 -0
@@ -189,6 +189,7 @@ import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids,
189189
190import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';190import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
191import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';191import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
192import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js';
192import {193import {
193 tag_map,194 tag_map,
194 tags,195 tags,
@@ -711,6 +712,7 @@ async function firstLoadInit() {
711 initAuthorsNote();712 initAuthorsNote();
712 await initPersonas();713 await initPersonas();
713 await initSlashCommandAutoComplete();714 await initSlashCommandAutoComplete();
715 initMacroAutoComplete();
714 initWorldInfo();716 initWorldInfo();
715 initHorde();717 initHorde();
716 initRossMods();718 initRossMods();
@@ -8028,6 +8030,7 @@ export async function messageEdit(editMessageId) {
8028 const editTextArea = document.createElement('textarea');8030 const editTextArea = document.createElement('textarea');
8029 editTextArea.id = 'curEditTextarea';8031 editTextArea.id = 'curEditTextarea';
8030 editTextArea.className = 'edit_textarea mdHotkeys';8032 editTextArea.className = 'edit_textarea mdHotkeys';
8033 editTextArea.dataset.macros = '';
8031 messageText.append(editTextArea);8034 messageText.append(editTextArea);
80328035
8033 const text = trimSpaces(editMessage.mes || '');8036 const text = trimSpaces(editMessage.mes || '');
public/scripts/autocomplete/AutoComplete.js+36 -2
@@ -275,6 +275,7 @@ export class AutoComplete {
275 //TODO check if isInput and isForced are both required275 //TODO check if isInput and isForced are both required
276 this.text = this.textarea.value;276 this.text = this.textarea.value;
277 this.isReplaceable = false;277 this.isReplaceable = false;
278 this.isShowForced = isForced; // Store forced state for checkIfActivate to access
278279
279 if (document.activeElement != this.textarea) {280 if (document.activeElement != this.textarea) {
280 // only show with textarea in focus281 // only show with textarea in focus
@@ -442,7 +443,7 @@ export class AutoComplete {
442 } else if (!this.isReplaceable && this.result.length > 1) {443 } else if (!this.isReplaceable && this.result.length > 1) {
443 return this.hide();444 return this.hide();
444 }445 }
445 this.selectedItem = this.result[0];446 this.selectedItem = this.selectDefaultItem(this.result);
446 this.isActive = true;447 this.isActive = true;
447 this.wasForced = isForced;448 this.wasForced = isForced;
448 this.renderDebounced();449 this.renderDebounced();
@@ -600,7 +601,22 @@ export class AutoComplete {
600 if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {601 if (location.bottom < rect.top || location.top > rect.bottom || location.left < rect.left || location.left > rect.right) {
601 return this.hide();602 return this.hide();
602 }603 }
603 const left = Math.max(rect.left, location.left) - layerRect.left;604 let 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
604 this.detailsWrap.style.setProperty('--targetOffset', `${left}`);620 this.detailsWrap.style.setProperty('--targetOffset', `${left}`);
605 if (this.isReplaceable) {621 if (this.isReplaceable) {
606 this.detailsWrap.classList.remove('full');622 this.detailsWrap.classList.remove('full');
@@ -711,6 +727,24 @@ export class AutoComplete {
711727
712728
713 /**729 /**
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 /**
714 * Mark the item at newIdx in the autocomplete list as selected.748 * Mark the item at newIdx in the autocomplete list as selected.
715 * @param {number} newIdx749 * @param {number} newIdx
716 */750 */
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+83 -0
@@ -1015,6 +1015,89 @@ export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
1015}1015}
10161016
1017/**1017/**
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 */
1021export 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/**
1018 * Autocomplete option for closing a scoped macro.1101 * Autocomplete option for closing a scoped macro.
1019 * Suggests {{/macroName}} to close an unclosed scoped macro.1102 * Suggests {{/macroName}} to close an unclosed scoped macro.
1020 */1103 */
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
14import { power_user } from '../power-user.js';
15import { AutoComplete, AUTOCOMPLETE_STATE } from './AutoComplete.js';
16import { findMacroAtCursor, findUnclosedScopes, getMacroAutoCompleteAt } from './MacroAutoCompleteHelper.js';
17
18/** Custom attribute name used to mark elements that support macro autocomplete */
19export const MACRO_AUTOCOMPLETE_ATTRIBUTE = 'data-macros';
20
21/** Attribute to control autocomplete visibility: 'always' (force show) or 'hide' (never show) */
22export const MACRO_AUTOCOMPLETE_MODE_ATTRIBUTE = 'data-macros-autocomplete';
23
24/** Generic attribute to control autocomplete popup style/size (used by AutoComplete) */
25export const MACRO_AUTOCOMPLETE_STYLE_ATTRIBUTE = 'data-macros-autocomplete-style';
26
27/**
28 * @readonly
29 * @enum {string}
30 */
31export 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 */
44export 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 */
52const initializedElements = new WeakSet();
53
54/** @type {WeakMap<HTMLElement, AutoComplete>} Map elements to their autocomplete instances */
55const 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 */
71function 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 */
119export 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 */
141function 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 */
158function 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 */
175function 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 */
197function 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 */
211function 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 */
236const 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 */
262export 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 */
295export 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
9import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
10import {
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';
24import { macros as macroSystem } from '../macros/macro-system.js';
25import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
26import { MacroParser } from '../macros/engine/MacroParser.js';
27import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
28import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
29import { chat_metadata } from '/script.js';
30import { 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 */
67export 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 */
90export 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 */
141export 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 */
345export 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 */
482export 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 */
599export 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 */
668export 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 */
700export 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 */
955export 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() {
2239 wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');2239 wrapper.classList.add('flexFlowColumn', 'justifyCenter', 'alignitemscenter');
2240 const textarea = document.createElement('textarea');2240 const textarea = document.createElement('textarea');
2241 textarea.dataset.for = broId;2241 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 }
2242 textarea.value = String(contentEditable ? bro[0].innerText : bro.val());2247 textarea.value = String(contentEditable ? bro[0].innerText : bro.val());
2243 textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');2248 textarea.classList.add('height100p', 'wide100p', 'maximized_textarea');
2244 bro.hasClass('monospace') && textarea.classList.add('monospace');2249 bro.hasClass('monospace') && textarea.classList.add('monospace');
public/scripts/power-user.js+11 -0
@@ -326,6 +326,8 @@ export const power_user = {
326 right: AUTOCOMPLETE_WIDTH.CHAT,326 right: AUTOCOMPLETE_WIDTH.CHAT,
327 },327 },
328 select: AUTOCOMPLETE_SELECT_KEY.TAB + AUTOCOMPLETE_SELECT_KEY.ENTER,328 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,
329 },331 },
330 parser: {332 parser: {
331 /**@type {Object.<PARSER_FLAG,boolean>} */333 /**@type {Object.<PARSER_FLAG,boolean>} */
@@ -1590,6 +1592,9 @@ export async function loadPowerUserSettings(settings, data) {
1590 if (power_user.stscript.autocomplete.select === undefined) {1592 if (power_user.stscript.autocomplete.select === undefined) {
1591 power_user.stscript.autocomplete.select = defaultStscript.autocomplete.select;1593 power_user.stscript.autocomplete.select = defaultStscript.autocomplete.select;
1592 }1594 }
1595 if (power_user.stscript.autocomplete.showInAllMacroFields === undefined) {
1596 power_user.stscript.autocomplete.showInAllMacroFields = defaultStscript.autocomplete.showInAllMacroFields;
1597 }
1593 }1598 }
1594 if (power_user.stscript.parser === undefined) {1599 if (power_user.stscript.parser === undefined) {
1595 power_user.stscript.parser = defaultStscript.parser;1600 power_user.stscript.parser = defaultStscript.parser;
@@ -1727,6 +1732,7 @@ export async function loadPowerUserSettings(settings, data) {
17271732
1728 $('#stscript_autocomplete_state').val(power_user.stscript.autocomplete.state).trigger('input');1733 $('#stscript_autocomplete_state').val(power_user.stscript.autocomplete.state).trigger('input');
1729 $('#stscript_autocomplete_autoHide').prop('checked', power_user.stscript.autocomplete.autoHide ?? false).trigger('input');1734 $('#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');
1730 $('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy');1736 $('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy');
1731 $('#stscript_autocomplete_style').val(power_user.stscript.autocomplete.style ?? 'theme');1737 $('#stscript_autocomplete_style').val(power_user.stscript.autocomplete.style ?? 'theme');
1732 document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete.style);1738 document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete.style);
@@ -4079,6 +4085,11 @@ jQuery(() => {
4079 saveSettingsDebounced();4085 saveSettingsDebounced();
4080 });4086 });
40814087
4088 $('#stscript_autocomplete_showInAllMacroFields').on('input', function () {
4089 power_user.stscript.autocomplete.showInAllMacroFields = !!$(this).prop('checked');
4090 saveSettingsDebounced();
4091 });
4092
4082 $('#stscript_matching').on('change', function () {4093 $('#stscript_matching').on('change', function () {
4083 const value = $(this).find(':selected').val();4094 const value = $(this).find(':selected').val();
4084 power_user.stscript.matching = String(value);4095 power_user.stscript.matching = String(value);
public/scripts/slash-commands/SlashCommandParser.js+25 -796
@@ -16,30 +16,14 @@ import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNa
16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
18import {18import {
19 EnhancedMacroAutoCompleteOption,19 findUnclosedScopes,
20 MacroFlagAutoCompleteOption,20 buildMacroAutoCompleteResult,
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';
31import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
32import { MacroParser } from '../macros/engine/MacroParser.js';
33import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
34import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';22import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
35import { SlashCommandDebugController } from './SlashCommandDebugController.js';23import { SlashCommandDebugController } from './SlashCommandDebugController.js';
36import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';24import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
37import { SlashCommandBreak } from './SlashCommandBreak.js';25import { SlashCommandBreak } from './SlashCommandBreak.js';
38import { macros as macroSystem } from '../macros/macro-system.js';26import { parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
39import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
40import { chat_metadata } from '/script.js';
41import { extension_settings } from '../extensions.js';
42import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
4327
44/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */28/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
45/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */29/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -507,250 +491,33 @@ export class SlashCommandParser {
507 ?? null491 ?? null
508 ;492 ;
509 if (childClosure !== null) return null;493 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 context496 if (macroEntry) {
513 const cursorInMacro = index - macro.start - 2; // -2 for {{497 // Build macro info object for shared function
514 const macroContent = text.slice(macro.start + 2, macro.end - (text.slice(macro.end - 2, macro.end) === '}}' ? 2 : 0));498 const macroContent = text.slice(macroEntry.start + 2, macroEntry.end - (text.slice(macroEntry.end - 2, macroEntry.end) === '}}' ? 2 : 0));
515 const context = parseMacroContext(macroContent, cursorInMacro);499 const macro = {
516500 start: macroEntry.start,
517 // Check if cursor is at/after the closing }} - macro syntax is complete501 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,
536 };503 };
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;
637 }508 }
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
716 const textUpToCursor = text.slice(0, index);511 const textUpToCursor = text.slice(0, index);
717 const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);512 const unclosedScopes = findUnclosedScopes(textUpToCursor);
718 if (unclosedScopes.length > 0) {513 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 info515 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 context518 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 }
754 }521 }
755 if (executor.name == ':') {522 if (executor.name == ':') {
756 const options = this.scopeIndex[this.commandIndex.indexOf(executor)]523 const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
@@ -785,544 +552,6 @@ export class SlashCommandParser {
785 }552 }
786553
787 /**554 /**
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 /**
1326 * Moves the index <length> number of characters forward and returns the last character taken.555 * Moves the index <length> number of characters forward and returns the last character taken.
1327 * @param {number} length Number of characters to take.556 * @param {number} length Number of characters to take.
1328 * @param {boolean} keep Whether to add the characters to the kept text.557 * @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
2844 const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;2844 const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;
2845 const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);2845 const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
2846 input.data('uid', entry.uid);2846 input.data('uid', entry.uid);
2847 input[0].dataset.macros = ''; // active
2847 input.on('click', function (event) {2848 input.on('click', function (event) {
2848 event.stopPropagation();2849 event.stopPropagation();
2849 });2850 });
@@ -3579,6 +3580,7 @@ export async function getWorldEntry(name, data, entry) {
3579 const contentInput = editTemplate.find('textarea[name="content"]');3580 const contentInput = editTemplate.find('textarea[name="content"]');
3580 contentInput.data('uid', entry.uid);3581 contentInput.data('uid', entry.uid);
3581 contentInput.attr('id', contentInputId);3582 contentInput.attr('id', contentInputId);
3583 contentInput[0].dataset.macros = ''; // active
3582 contentInput.on('input', async function (_, { skipCount, noSave } = {}) {3584 contentInput.on('input', async function (_, { skipCount, noSave } = {}) {
3583 const uid = $(this).data('uid');3585 const uid = $(this).data('uid');
3584 const value = $(this).val();3586 const value = $(this).val();
public/style.css+39 -0
@@ -1654,6 +1654,13 @@ button {
1654 width: 50vw;1654 width: 50vw;
1655 }1655 }
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
1657 &:after {1664 &:after {
1658 content: "";1665 content: "";
1659 flex: 1 1 0;1666 flex: 1 1 0;
@@ -1706,6 +1713,38 @@ button {
1706 width: 25vw;1713 width: 25vw;
1707 }1714 }
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
1709 &.left {1748 &.left {
1710 &:before {1749 &:before {
1711 flex: 0 1 calc(var(--targetOffset) * 1px - 25vw);1750 flex: 0 1 calc(var(--targetOffset) * 1px - 25vw);