Macros 2.0 (v0.7.3) - Variable Shorthand: Comparison Operators & Autocomplete Improvements (#5050) * feat: Add numeric comparison operators (>, >=, <, <=) to variable shorthand syntax - Added four new comparison operators for local and global variables - Implemented greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual operations in MacroCstWalker - Updated lexer to recognize >=, >, <=, < operators (longer patterns before shorter to avoid conflicts) - Simplified parser by consolidating operator alternatives into single OR block - Enhanced autocomplete with operator definitions, examples, and usage * Improve autocomplete for variable shorthand operators with cursor-aware filtering - Track `variableNameEnd` and `variableOperatorEnd` positions in parsed macro context for accurate cursor position checks - Add `isShortOperatorPrefix()` helper to detect operators that could be prefixes of longer ones (e.g., `>` → `>=`) - Fix operator autocomplete to show longer variants when typing short operators (typing `>` now shows both `>` and `>=`) - Show operator suggestions immediately when variable

42155ecebf064d1a3476927fe63f9d78594d7d9d

Wolfsblvt <wolfsblvt@gmail.com>

Signed
6 files changed, +313 -87Showing whitespace changes
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+75 -7
@@ -39,7 +39,9 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos
3939 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
4040 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
4141 * @property {string} variableName - The variable name being typed (after the prefix).
42+ * @property {number} variableNameEnd - The end of the variable name (for partial matches).
4243 * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
44+ * @property {number} variableOperatorEnd - The end of the variable operator (for partial matches).
4345 * @property {string} variableValue - The value after the operator (for = and +=).
4446 * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
4547 * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
@@ -499,13 +501,13 @@ export const VariableShorthandDefinitions = new Map([
499501 type: VariableShorthandType.LOCAL,
500502 name: 'Local Variable',
501503 description: 'Access or modify a local variable (scoped to current chat).',
502504 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
503505 }],
504506 [VariableShorthandType.GLOBAL, {
505507 type: VariableShorthandType.GLOBAL,
506508 name: 'Global Variable',
507509 description: 'Access or modify a global variable (shared across all chats).',
508510 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
509511 }],
510512]);
511513
@@ -633,6 +635,10 @@ export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
633635 `{{${prefix}myvar ??= value}} - Set if undefined, get value`,
634636 `{{${prefix}myvar == test}} - Compare (returns true/false)`,
635637 `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,
638+ `{{${prefix}score > 10}} - Greater than (numeric, returns true/false)`,
639+ `{{${prefix}score >= 10}} - Greater than or equal (numeric)`,
640+ `{{${prefix}score < 10}} - Less than (numeric, returns true/false)`,
641+ `{{${prefix}score <= 10}} - Less than or equal (numeric)`,
636642 ];
637643 for (const ex of examples) {
638644 const li = document.createElement('li');
@@ -810,6 +816,10 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption {
810816 `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
811817 `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
812818 `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,
819+ `{{${prefix}${this.#varName} > 10}} - Greater than (numeric)`,
820+ `{{${prefix}${this.#varName} >= 10}} - Greater than or equal (numeric)`,
821+ `{{${prefix}${this.#varName} < 10}} - Less than (numeric)`,
822+ `{{${prefix}${this.#varName} <= 10}} - Less than or equal (numeric)`,
813823 ];
814824 for (const ex of examples) {
815825 const li = document.createElement('li');
@@ -824,6 +834,18 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption {
824834}
825835
826836/**
837+ * Checks if an operator is a short one that could be a prefix of a longer operator.
838+ * For example, '>' is a prefix of '>=', '<' is a prefix of '<='.
839+ * @param {string} op - The operator to check.
840+ * @returns {boolean} True if the operator could be a prefix of a longer operator.
841+ */
842+function isShortOperatorPrefix(op) {
843+ // These operators could have longer variants typed after them
844+ const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!'];
845+ return shortPrefixes.includes(op);
846+}
847+
848+/**
827849 * Variable shorthand operators with metadata.
828850 * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
829851 */
@@ -894,6 +916,30 @@ export const VariableOperatorDefinitions = new Map([
894916 description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
895917 needsValue: true,
896918 }],
919+ ['>', {
920+ symbol: '>',
921+ name: 'Greater Than',
922+ description: 'Numeric comparison. Returns "true" if variable is greater than value, "false" otherwise.',
923+ needsValue: true,
924+ }],
925+ ['>=', {
926+ symbol: '>=',
927+ name: 'Greater Than or Equal',
928+ description: 'Numeric comparison. Returns "true" if variable is greater than or equal to value, "false" otherwise.',
929+ needsValue: true,
930+ }],
931+ ['<', {
932+ symbol: '<',
933+ name: 'Less Than',
934+ description: 'Numeric comparison. Returns "true" if variable is less than value, "false" otherwise.',
935+ needsValue: true,
936+ }],
937+ ['<=', {
938+ symbol: '<=',
939+ name: 'Less Than or Equal',
940+ description: 'Numeric comparison. Returns "true" if variable is less than or equal to value, "false" otherwise.',
941+ needsValue: true,
942+ }],
897943]);
898944
899945/**
@@ -1224,19 +1270,35 @@ export function parseMacroContext(macroText, cursorOffset) {
12241270 } else if (operatorText.startsWith('!=')) {
12251271 variableOperator = '!=';
12261272 i += 2;
1273+ } else if (operatorText.startsWith('>=')) {
1274+ variableOperator = '>=';
1275+ i += 2;
1276+ } else if (operatorText.startsWith('>')) {
1277+ variableOperator = '>';
1278+ i += 1;
1279+ } else if (operatorText.startsWith('<=')) {
1280+ variableOperator = '<=';
1281+ i += 2;
1282+ } else if (operatorText.startsWith('<')) {
1283+ variableOperator = '<';
1284+ i += 1;
12271285 } else if (operatorText.startsWith('=')) {
12281286 variableOperator = '=';
12291287 i += 1;
12301288 } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) {
12311289 // Partial operator prefix - user is typing an operator
12321290 partialOperator = operatorText[0];
12331291 } else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) {
12341292 // There's non-whitespace after the variable name that isn't a valid operator
12351293 // This is an invalid trailing character (e.g., $my$ or .var@test)
1294+ // Exception: } is the closing brace, not an invalid char
12361295 hasInvalidTrailingChars = true;
12371296 invalidTrailingChars = operatorText.trim();
12381297 }
12391298
1299+ // Track where the operator ends (for cursor position checks)
1300+ const variableOperatorEnd = i;
1301+
12401302 // Check if operator requires a value
12411303 const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
12421304 const operatorNeedsValue = operatorDef?.needsValue ?? false;
@@ -1256,11 +1318,15 @@ export function parseMacroContext(macroText, cursorOffset) {
12561318 // Cursor is before the prefix - still in flags area conceptually
12571319 isTypingVariableName = false;
12581320 } else if (cursorOffset <= variableNameEnd) {
12591321 // Cursor is in the variable name area (including at the end)
12601322 isTypingVariableName = true;
12611323 } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars) {
12621324 // Cursor is after variable name but no operator yet (and no invalid chars)
12631325 // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'
1326+ isTypingOperator = true;
1327+ } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {
1328+ // Short operator that could be prefix of longer one (e.g., > could become >=)
1329+ // But ONLY if cursor is still in the operator area, not past it into value
12641330 isTypingOperator = true;
12651331 } else if (operatorNeedsValue) {
12661332 // Operator that requires value - cursor is in value area
@@ -1292,7 +1358,9 @@ export function parseMacroContext(macroText, cursorOffset) {
12921358 isVariableShorthand,
12931359 variablePrefix,
12941360 variableName,
1361+ variableNameEnd,
12951362 variableOperator,
1363+ variableOperatorEnd,
12961364 variableValue,
12971365 isTypingVariableName,
12981366 isTypingOperator,
public/scripts/macros/engine/MacroCstWalker.js+60 -0
@@ -574,6 +574,22 @@ class MacroCstWalker {
574574 operation = 'notEquals';
575575 hasValueExpr = true;
576576 break;
577+ case '>':
578+ operation = 'greaterThan';
579+ hasValueExpr = true;
580+ break;
581+ case '>=':
582+ operation = 'greaterThanOrEqual';
583+ hasValueExpr = true;
584+ break;
585+ case '<':
586+ operation = 'lessThan';
587+ hasValueExpr = true;
588+ break;
589+ case '<=':
590+ operation = 'lessThanOrEqual';
591+ hasValueExpr = true;
592+ break;
577593 default:
578594 logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` });
579595 break;
@@ -714,6 +730,50 @@ class MacroCstWalker {
714730 return currentValue !== compareValue ? 'true' : 'false';
715731 }
716732
733+ case 'greaterThan': {
734+ // Numeric greater than comparison
735+ const currentNum = Number(vars.get(varName));
736+ const compareNum = Number(lazyValue());
737+ if (isNaN(currentNum) || isNaN(compareNum)) {
738+ logMacroRuntimeWarning({ message: `Variable shorthand ">" operator requires numeric values. Got: "${vars.get(varName)}" > "${lazyValue()}"` });
739+ return 'false';
740+ }
741+ return currentNum > compareNum ? 'true' : 'false';
742+ }
743+
744+ case 'greaterThanOrEqual': {
745+ // Numeric greater than or equal comparison
746+ const currentNum = Number(vars.get(varName));
747+ const compareNum = Number(lazyValue());
748+ if (isNaN(currentNum) || isNaN(compareNum)) {
749+ logMacroRuntimeWarning({ message: `Variable shorthand ">=" operator requires numeric values. Got: "${vars.get(varName)}" >= "${lazyValue()}"` });
750+ return 'false';
751+ }
752+ return currentNum >= compareNum ? 'true' : 'false';
753+ }
754+
755+ case 'lessThan': {
756+ // Numeric less than comparison
757+ const currentNum = Number(vars.get(varName));
758+ const compareNum = Number(lazyValue());
759+ if (isNaN(currentNum) || isNaN(compareNum)) {
760+ logMacroRuntimeWarning({ message: `Variable shorthand "<" operator requires numeric values. Got: "${vars.get(varName)}" < "${lazyValue()}"` });
761+ return 'false';
762+ }
763+ return currentNum < compareNum ? 'true' : 'false';
764+ }
765+
766+ case 'lessThanOrEqual': {
767+ // Numeric less than or equal comparison
768+ const currentNum = Number(vars.get(varName));
769+ const compareNum = Number(lazyValue());
770+ if (isNaN(currentNum) || isNaN(compareNum)) {
771+ logMacroRuntimeWarning({ message: `Variable shorthand "<=" operator requires numeric values. Got: "${vars.get(varName)}" <= "${lazyValue()}"` });
772+ return 'false';
773+ }
774+ return currentNum <= compareNum ? 'true' : 'false';
775+ }
776+
717777 default:
718778 logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` });
719779 return '';
public/scripts/macros/engine/MacroLexer.js+12 -0
@@ -132,6 +132,14 @@ const Tokens = Object.freeze({
132132 DoubleEquals: createToken({ name: 'Var.DoubleEquals', pattern: /==/ }),
133133 /** Not equals comparison operator (`!=`) - compares variable to value, returns inverted result */
134134 NotEquals: createToken({ name: 'Var.NotEquals', pattern: /!=/ }),
135+ /** Greater than or equal comparison operator (`>=`) - must come before GreaterThan */
136+ GreaterThanOrEqual: createToken({ name: 'Var.GreaterThanOrEqual', pattern: />=/ }),
137+ /** Greater than comparison operator (`>`) */
138+ GreaterThan: createToken({ name: 'Var.GreaterThan', pattern: />/ }),
139+ /** Less than or equal comparison operator (`<=`) - must come before LessThan */
140+ LessThanOrEqual: createToken({ name: 'Var.LessThanOrEqual', pattern: /<=/ }),
141+ /** Less than comparison operator (`<`) */
142+ LessThan: createToken({ name: 'Var.LessThan', pattern: /</ }),
135143 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */
136144 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),
137145 /** Set operator (`=`) */
@@ -258,6 +266,10 @@ const Def = {
258266 enter(Tokens.Var.Operators.MinusEquals, modes.var_value, { andExits: modes.var_after_identifier }),
259267 enter(Tokens.Var.Operators.DoubleEquals, modes.var_value, { andExits: modes.var_after_identifier }),
260268 enter(Tokens.Var.Operators.NotEquals, modes.var_value, { andExits: modes.var_after_identifier }),
269+ enter(Tokens.Var.Operators.GreaterThanOrEqual, modes.var_value, { andExits: modes.var_after_identifier }),
270+ enter(Tokens.Var.Operators.GreaterThan, modes.var_value, { andExits: modes.var_after_identifier }),
271+ enter(Tokens.Var.Operators.LessThanOrEqual, modes.var_value, { andExits: modes.var_after_identifier }),
272+ enter(Tokens.Var.Operators.LessThan, modes.var_value, { andExits: modes.var_after_identifier }),
261273 enter(Tokens.Var.Operators.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }),
262274 enter(Tokens.Var.Operators.Equals, modes.var_value, { andExits: modes.var_after_identifier }),
263275 // If we see the end, exit
public/scripts/macros/engine/MacroParser.js+16 -50
@@ -92,65 +92,31 @@ class MacroParser extends CstParser {
9292 $.OPTION2(() => $.SUBRULE($.variableOperator));
9393 });
9494
9595 // Variable operator: ++, --, = value, += value, -= value, ||, ??, ||=, ??=, ==, !=, >, >=, <, <=
9696 $.variableOperator = $.RULE('variableOperator', () => {
9797 $.OR4([
9898 { ALT: () => $.CONSUME(Tokens.Var.Operators.Increment, { LABEL: 'Var.operator' }) },
9999 { ALT: () => $.CONSUME(Tokens.Var.Operators.Decrement, { LABEL: 'Var.operator' }) },
100100 {
101101 ALT: () => {
102- $.CONSUME(Tokens.Var.Operators.NullishCoalescingEquals, { LABEL: 'Var.operator' });
102+ $.OR5([
103+ { ALT: () => $.CONSUME(Tokens.Var.Operators.NullishCoalescingEquals, { LABEL: 'Var.operator' }) },
104+ { ALT: () => $.CONSUME(Tokens.Var.Operators.NullishCoalescing, { LABEL: 'Var.operator' }) },
105+ { ALT: () => $.CONSUME(Tokens.Var.Operators.LogicalOrEquals, { LABEL: 'Var.operator' }) },
106+ { ALT: () => $.CONSUME(Tokens.Var.Operators.LogicalOr, { LABEL: 'Var.operator' }) },
107+ { ALT: () => $.CONSUME(Tokens.Var.Operators.MinusEquals, { LABEL: 'Var.operator' }) },
108+ { ALT: () => $.CONSUME(Tokens.Var.Operators.DoubleEquals, { LABEL: 'Var.operator' }) },
109+ { ALT: () => $.CONSUME(Tokens.Var.Operators.NotEquals, { LABEL: 'Var.operator' }) },
110+ { ALT: () => $.CONSUME(Tokens.Var.Operators.GreaterThanOrEqual, { LABEL: 'Var.operator' }) },
111+ { ALT: () => $.CONSUME(Tokens.Var.Operators.GreaterThan, { LABEL: 'Var.operator' }) },
112+ { ALT: () => $.CONSUME(Tokens.Var.Operators.LessThanOrEqual, { LABEL: 'Var.operator' }) },
113+ { ALT: () => $.CONSUME(Tokens.Var.Operators.LessThan, { LABEL: 'Var.operator' }) },
114+ { ALT: () => $.CONSUME(Tokens.Var.Operators.PlusEquals, { LABEL: 'Var.operator' }) },
115+ { ALT: () => $.CONSUME(Tokens.Var.Operators.Equals, { LABEL: 'Var.operator' }) },
116+ ]);
103117 $.SUBRULE($.variableValue, { LABEL: 'Var.value' });
104118 },
105119 },
106- {
107- ALT: () => {
108- $.CONSUME(Tokens.Var.Operators.NullishCoalescing, { LABEL: 'Var.operator' });
109- $.SUBRULE2($.variableValue, { LABEL: 'Var.value' });
110- },
111- },
112- {
113- ALT: () => {
114- $.CONSUME(Tokens.Var.Operators.LogicalOrEquals, { LABEL: 'Var.operator' });
115- $.SUBRULE3($.variableValue, { LABEL: 'Var.value' });
116- },
117- },
118- {
119- ALT: () => {
120- $.CONSUME(Tokens.Var.Operators.LogicalOr, { LABEL: 'Var.operator' });
121- $.SUBRULE4($.variableValue, { LABEL: 'Var.value' });
122- },
123- },
124- {
125- ALT: () => {
126- $.CONSUME(Tokens.Var.Operators.MinusEquals, { LABEL: 'Var.operator' });
127- $.SUBRULE5($.variableValue, { LABEL: 'Var.value' });
128- },
129- },
130- {
131- ALT: () => {
132- $.CONSUME(Tokens.Var.Operators.DoubleEquals, { LABEL: 'Var.operator' });
133- $.SUBRULE6($.variableValue, { LABEL: 'Var.value' });
134- },
135- },
136- {
137- ALT: () => {
138- $.CONSUME(Tokens.Var.Operators.NotEquals, { LABEL: 'Var.operator' });
139- $.SUBRULE7($.variableValue, { LABEL: 'Var.value' });
140- },
141- },
142- {
143- ALT: () => {
144- $.CONSUME(Tokens.Var.Operators.PlusEquals, { LABEL: 'Var.operator' });
145- $.SUBRULE8($.variableValue, { LABEL: 'Var.value' });
146- },
147- },
148- {
149- ALT: () => {
150- $.CONSUME(Tokens.Var.Operators.Equals, { LABEL: 'Var.operator' });
151- $.SUBRULE9($.variableValue, { LABEL: 'Var.value' });
152- },
153- },
154120 ]);
155121 });
156122
public/scripts/slash-commands/SlashCommandParser.js+54 -30
@@ -658,17 +658,13 @@ export class SlashCommandParser {
658658 resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
659659 }
660660 } else if (context.isTypingOperator) {
661661 // Typing operator: identifier = partial operator textor (ifcurrent any)operator, start = after variable name
662- // Using partial operator as identifier ensures cursor is within name range for filtering
662+ resultIdentifier = context.partialOperator || context.variableOperator || '';
663- resultIdentifier = context.partialOperator || '';
663+ // Use actual variableNameEnd position from parsing (accounts for whitespace)
664- if (prefixIndex >= 0) {
664+ resultStart = macro.start + 2 + context.variableNameEnd;
665665 // Start afterSkip prefixwhitespace +between variable name lengthand operator
666- resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length;
666+ while (resultStart < index && /\s/.test(text[resultStart])) {
667- // If no partial operator (just whitespace after var name), set start to cursor
667+ resultStart++;
668- // This ensures cursor is in the name range for filtering
669- if (!context.partialOperator) {
670- resultStart = index;
671- }
672668 }
673669 } else if (context.isOperatorComplete) {
674670 // Operator complete (++ or --) - show context but no value input needed
@@ -677,15 +673,13 @@ export class SlashCommandParser {
677673 } else if (context.hasInvalidTrailingChars) {
678674 // Invalid chars after variable name: show the invalid chars for warning
679675 resultIdentifier = context.invalidTrailingChars || '';
680- if (prefixIndex >= 0) {
676+ // Use actual variableNameEnd position from parsing
681677 resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.lengthvariableNameEnd;
682- }
683678 } else if (context.isTypingValue) {
684679 // Typing value: identifier = value being typed, start = after operator
685680 resultIdentifier = context.variableValue;
686- if (prefixIndex >= 0) {
681+ // Use actual operatorEnd position from parsing (accounts for whitespace)
687- const operatorLen = context.variableOperator?.length ?? 0;
682+ resultStart = macro.start + 2 + context.variableOperatorEnd;
688- resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length + operatorLen;
689683 // Skip any whitespace between operator and value
690684 while (resultStart < index && /\s/.test(text[resultStart])) {
691685 resultStart++;
@@ -693,7 +687,6 @@ export class SlashCommandParser {
693687
694688 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
695689 makeNoOptionsText = () => 'Enter a variable value';
696- }
697690 } else {
698691 // Fallback: use variable name
699692 resultIdentifier = context.variableName;
@@ -952,16 +945,20 @@ export class SlashCommandParser {
952945 prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
953946 prefixOption.makeSelectable = false;
954947 prefixOption.sortPriority = 1; // Show at top
948+ prefixOption.matchProvider = () => true; // Always show regardless of filtering
955949 options.push(prefixOption);
956950 }
957951
958952 // If typing the variable name, suggest existing variables
959- if (context.isTypingVariableName) {
960953 // Get existing variable names from the appropriate scope
961954 // Filter to only include names that are valid for shorthand syntax
962955 const existingVariables = this.#getVariableNames(scope)
963956 .filter(name => isValidVariableShorthandName(name));
964957
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) {
965962 // Add existing variables that match the typed name
966963 for (const varName of existingVariables) {
967964 const option = new VariableNameAutoCompleteOption(varName, scope, false);
@@ -995,6 +992,20 @@ export class SlashCommandParser {
995992 }
996993 options.push(newVarOption);
997994 }
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+ }
9981009 }
9991010
10001011 // If there are invalid trailing characters after the variable name, show a warning
@@ -1026,14 +1037,23 @@ export class SlashCommandParser {
10261037 options.push(varNameOption);
10271038
10281039 // Then show available operators, filtered by partial prefix if any
1040+ // Also filter by current complete operator to show longer variants (e.g., > shows >=)
10291041 const partialOp = context.partialOperator || '';
1042+ const currentOp = context.variableOperator || '';
1043+ const filterPrefix = partialOp || currentOp;
10301044 for (const [, operatorDef] of VariableOperatorDefinitions) {
10311045 // Filter by partial operator prefix if user is typing one
1032- if (partialOp && !operatorDef.symbol.startsWith(partialOp)) {
1046+ // This allows typing ">" to show both ">" and ">="
1047+ if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) {
10331048 continue;
10341049 }
10351050 const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
1036- opOption.sortPriority = 5;
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+ }
10371057 // Always match operators when showing operator suggestions
10381058 opOption.matchProvider = () => true;
10391059 options.push(opOption);
@@ -1041,21 +1061,23 @@ export class SlashCommandParser {
10411061 }
10421062
10431063 // If typing value (after = or +=), no autocomplete needed - freeform text
10441064 // But we can show the current context for reference (greyed out, non-selectable)
10451065 if (context.isTypingValue && !context.isTypingOperator) {
10461066 // Show the current variable name as context (non-selectable)
10471067 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
10481068 varNameOption.valueProvider = () => ''; // Context only
1069+ varNameOption.makeSelectable = false;
10491070 varNameOption.sortPriority = 2;
10501071 varNameOption.matchProvider = () => true; // Always show
10511072 options.push(varNameOption);
10521073
10531074 // Show the operator that was used (non-selectable)
10541075 if (context.variableOperator) {
10551076 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
10561077 if (opDef) {
10571078 const opOption = new VariableOperatorAutoCompleteOption(opDef);
10581079 opOption.valueProvider = () => ''; // Already typed
1080+ opOption.makeSelectable = false;
10591081 opOption.sortPriority = 3;
10601082 opOption.matchProvider = () => true; // Always show
10611083 options.push(opOption);
@@ -1063,21 +1085,23 @@ export class SlashCommandParser {
10631085 }
10641086 }
10651087
10661088 // If operator is complete (++ or --), show context without value input (non-selectable)
10671089 if (context.isOperatorComplete && !context.isTypingOperator) {
10681090 // Show the current variable name as context (non-selectable)
10691091 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
10701092 varNameOption.valueProvider = () => ''; // Context only
1093+ varNameOption.makeSelectable = false;
10711094 varNameOption.sortPriority = 2;
10721095 varNameOption.matchProvider = () => true; // Always show
10731096 options.push(varNameOption);
10741097
10751098 // Show the operator that was used (non-selectable)
10761099 if (context.variableOperator) {
10771100 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
10781101 if (opDef) {
10791102 const opOption = new VariableOperatorAutoCompleteOption(opDef);
10801103 opOption.valueProvider = () => ''; // Already typed
1104+ opOption.makeSelectable = false;
10811105 opOption.sortPriority = 3;
10821106 opOption.matchProvider = () => true; // Always show
10831107 options.push(opOption);
tests/frontend/MacroEngine.e2e.js+96 -0
@@ -2699,6 +2699,102 @@ test.describe('MacroEngine', () => {
26992699 expect(output).toBe('true');
27002700 });
27012701
2702+ // {{.myvar > value}} - greater than comparison (numeric)
2703+ test('should return true when variable is greater than value with >', async ({ page }) => {
2704+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: '10' } });
2705+ expect(output).toBe('true');
2706+ });
2707+
2708+ test('should return false when variable is not greater than value with >', async ({ page }) => {
2709+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '5' } });
2710+ expect(output).toBe('false');
2711+ });
2712+
2713+ test('should return false when variable equals value with >', async ({ page }) => {
2714+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '10' } });
2715+ expect(output).toBe('false');
2716+ });
2717+
2718+ test('should return false for non-numeric values with >', async ({ page }) => {
2719+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: 'abc' } });
2720+ expect(output).toBe('false');
2721+ });
2722+
2723+ // {{.myvar >= value}} - greater than or equal comparison (numeric)
2724+ test('should return true when variable is greater than value with >=', async ({ page }) => {
2725+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 5}}', { local: { myvar: '10' } });
2726+ expect(output).toBe('true');
2727+ });
2728+
2729+ test('should return true when variable equals value with >=', async ({ page }) => {
2730+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '10' } });
2731+ expect(output).toBe('true');
2732+ });
2733+
2734+ test('should return false when variable is less than value with >=', async ({ page }) => {
2735+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '5' } });
2736+ expect(output).toBe('false');
2737+ });
2738+
2739+ // {{.myvar < value}} - less than comparison (numeric)
2740+ test('should return true when variable is less than value with <', async ({ page }) => {
2741+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '5' } });
2742+ expect(output).toBe('true');
2743+ });
2744+
2745+ test('should return false when variable is not less than value with <', async ({ page }) => {
2746+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: '10' } });
2747+ expect(output).toBe('false');
2748+ });
2749+
2750+ test('should return false when variable equals value with <', async ({ page }) => {
2751+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '10' } });
2752+ expect(output).toBe('false');
2753+ });
2754+
2755+ test('should return false for non-numeric values with <', async ({ page }) => {
2756+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: 'abc' } });
2757+ expect(output).toBe('false');
2758+ });
2759+
2760+ // {{.myvar <= value}} - less than or equal comparison (numeric)
2761+ test('should return true when variable is less than value with <=', async ({ page }) => {
2762+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '5' } });
2763+ expect(output).toBe('true');
2764+ });
2765+
2766+ test('should return true when variable equals value with <=', async ({ page }) => {
2767+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '10' } });
2768+ expect(output).toBe('true');
2769+ });
2770+
2771+ test('should return false when variable is greater than value with <=', async ({ page }) => {
2772+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 5}}', { local: { myvar: '10' } });
2773+ expect(output).toBe('false');
2774+ });
2775+
2776+ // Negative numbers with comparison operators
2777+ test('should handle negative numbers with > operator', async ({ page }) => {
2778+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar > -5}}', { local: { myvar: '0' } });
2779+ expect(output).toBe('true');
2780+ });
2781+
2782+ test('should handle negative numbers with < operator', async ({ page }) => {
2783+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 0}}', { local: { myvar: '-5' } });
2784+ expect(output).toBe('true');
2785+ });
2786+
2787+ // Decimal numbers with comparison operators
2788+ test('should handle decimal numbers with >= operator', async ({ page }) => {
2789+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 3.14}}', { local: { myvar: '3.14' } });
2790+ expect(output).toBe('true');
2791+ });
2792+
2793+ test('should handle decimal numbers with <= operator', async ({ page }) => {
2794+ const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 2.5}}', { local: { myvar: '2.49' } });
2795+ expect(output).toBe('true');
2796+ });
2797+
27022798 // Global variable versions of new operators
27032799 test('should use || with global variable', async ({ page }) => {
27042800 const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });