Repository: SillyTavern/SillyTavern (language: javascript)

Issue:
[FEATURE_REQUEST] Slash command for a multi-select pop-up
### Have you searched for similar requests?

Yes

### Is your feature request related to a problem? If so, please describe.

The `/buttons` command is great but I would like to be able to choose multiple options, e.g. checkboxes, rather than just the one button.

### Describe the solution you'd like

Either a separate script command like `/checkboxes` or an option for /buttons (e.g. `/buttons multi=true`) that brings up a pop-up with your provided list of options as individual check-boxes, and Submit/Cancel buttons at the bottom.

### Describe alternatives you've considered

Writing/using an extension is the "obvious" alternative.

Without that, I have an absolutely horrific `/while` loop that relies on LALib to move button options between selected and unselected arrays to track which options have been picked and which should still be selectable, popping up a new `/buttons` each iteration and then `/break`ing out once you pick the "Submit" option. Technically that's an alternative!

### Additional context

Wolfie told me to open an issue

### Priority

Low (Nice-to-have)

### Are you willing to test this on staging/unstable branch if this is implemented?

Yes


Discussion / hints:

Fix patch:
diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js
index 096da917a5..969348ae5f 100644
--- a/public/scripts/slash-commands.js
+++ b/public/scripts/slash-commands.js
@@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
 import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
 import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
 import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
-import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
+import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
 import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
 import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
 import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
@@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel
 import { accountStorage } from './util/AccountStorage.js';
 import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
 import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
+import { t } from './i18n.js';
 export {
     executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
 };
@@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() {
     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
         name: 'buttons',
         callback: buttonsCallback,
-        returns: 'clicked button label',
+        returns: 'clicked button label (or array of labels if multiple is enabled)',
         namedArgumentList: [
-            new SlashCommandNamedArgument(
-                'labels', 'button labels', [ARGUMENT_TYPE.LIST], true,
-            ),
+            SlashCommandNamedArgument.fromProps({
+                name: 'labels',
+                description: 'button labels',
+                typeList: [ARGUMENT_TYPE.LIST],
+                isRequired: true,
+            }),
+            SlashCommandNamedArgument.fromProps({
+                name: 'multiple',
+                description: 'if enabled multiple buttons can be clicked/toggled, and all clicked buttons are returned as an array',
+                typeList: [ARGUMENT_TYPE.BOOLEAN],
+                enumList: commonEnumProviders.boolean('trueFalse')(),
+                defaultValue: 'false',
+            }),
         ],
         unnamedArgumentList: [
-            new SlashCommandArgument(
-                'text', [ARGUMENT_TYPE.STRING], true,
-            ),
+            SlashCommandArgument.fromProps({
+                description: 'text',
+                typeList: [ARGUMENT_TYPE.STRING],
+                isRequired: true,
+            }),
         ],
         helpString: `
         <div>
@@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) {
     }
 }
 
+/**
+ * Displays a popup with buttons based on provided labels and handles button interactions.
+ *
+ * @param {object} args - Named arguments for the command
+ * @param {string} args.labels - JSON string of an array of button labels
+ * @param {string} [args.multiple=false] - Flag indicating if multiple buttons can be toggled
+ * @param {string} text - The text content to be displayed within the popup
+ *
+ * @returns {Promise<string>} - A promise that resolves to a string of the button labels selected
+ *                              If 'multiple' is true, returns a JSON string array of labels.
+ *                              If 'multiple' is false, returns a single label string.
+ */
 async function buttonsCallback(args, text) {
     try {
         /** @type {string[]} */
@@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) {
             return '';
         }
 
+        /** @type {Set<number>} */
+        const multipleToggledState = new Set();
+        const multiple = isTrueBoolean(args?.multiple);
+
         // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
         const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
 
@@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) {
 
             for (const [result, button] of resultToButtonMap) {
                 const buttonElement = document.createElement('div');
-                buttonElement.classList.add('menu_button', 'result-control', 'wide100p');
-                buttonElement.dataset.result = String(result);
-                buttonElement.addEventListener('click', async () => {
-                    await popup.complete(result);
-                });
+                buttonElement.classList.add('menu_button', 'wide100p');
+
+                if (multiple) {
+                    buttonElement.classList.add('toggleable');
+                    buttonElement.dataset.toggleValue = String(result);
+                    buttonElement.addEventListener('click', async () => {
+                        buttonElement.classList.toggle('toggled');
+                        if (buttonElement.classList.contains('toggled')) {
+                            multipleToggledState.add(result);
+                        } else {
+                            multipleToggledState.delete(result);
+                        }
+                    });
+                } else {
+                    buttonElement.classList.add('result-control');
+                    buttonElement.dataset.result = String(result);
+                }
+
                 buttonElement.innerText = button;
                 buttonContainer.appendChild(buttonElement);
             }
@@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) {
             popupContainer.style.flexDirection = 'column';
             popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
 
-            popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'Cancel', allowVerticalScrolling: true });
+            popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: multiple ? t`Ok` : t`Cancel`, allowVerticalScrolling: true });
             popup.show()
-                .then((result => resolve(typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : '')))
+                .then((result => resolve(getResult(result))))
                 .catch(() => resolve(''));
+
+            /** @returns {string} @param {string|number|boolean} result */
+            function getResult(result) {
+                if (multiple) {
+                    const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r) ?? '') : [];
+                    return JSON.stringify(array);
+                }
+                return typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : '';
+            }
         });
     } catch {
         return '';
diff --git a/public/style.css b/public/style.css
index 7fa72d91ba..13a1267abc 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2911,6 +2911,35 @@ select option:not(:checked) {
     pointer-events: none;
 }
 
+.menu_button.toggleable {
+    padding-left: 20px;
+}
+
+.menu_button.toggleable.toggled {
+    border-color: var(--active);
+}
+
+.menu_button.toggleable:not(.toggled) {
+    filter: brightness(80%);
+}
+
+.menu_button.toggleable::before {
+    font-family: "Font Awesome 6 Free";
+    margin-left: 10px;
+    position: absolute;
+    left: 0;
+}
+
+.menu_button.toggleable.toggled::before {
+    content: "\f00c";
+    color: var(--active);
+}
+
+.menu_button.toggleable:not(.toggled)::before {
+    content: "\f00d";
+    color: var(--fullred);
+}
+
 .fav_on {
     color: var(--golden) !important;
 }
@@ -2921,7 +2950,7 @@ select option:not(:checked) {
 }
 
 .menu_button.togglable:not(.toggleEnabled) {
-    color: red;
+    color: var(--fullred);
 }
 
 .displayBlock {


Test patch: