From 3e2f91f6b3f2754179acf7d593d4bb1a8b710b2f Mon Sep 17 00:00:00 2001 From: Ghislain LE MEUR Date: Mon, 11 May 2026 14:45:34 +0200 Subject: [PATCH] feat(clarify): add multi-select (checkbox) support to clarify tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `multi_select` boolean parameter enabling checkbox-style multi-choice questions (Space to toggle, Enter to confirm). Backward compatible — defaults to single-select when omitted. - tools/clarify_tool.py: schema + handler + _parse_multi_select_response - run_agent.py: both dispatch points pass multi_select - cli.py: checkbox UI, key bindings, rendering, edge cases - hermes_cli/callbacks.py: TUI fallback callback - hermes_cli/oneshot.py: oneshot multi-select message - tests/tools/test_clarify_tool.py: 12 new tests (35 total) --- cli.py | 138 ++++++++++++++++++++--- hermes_cli/callbacks.py | 8 +- hermes_cli/oneshot.py | 7 +- tests/tools/test_clarify_tool.py | 186 +++++++++++++++++++++++++++++++ tools/clarify_tool.py | 85 ++++++++++++-- 5 files changed, 398 insertions(+), 26 deletions(-) diff --git a/cli.py b/cli.py index a0e4df9f2227..3b915ac56666 100644 --- a/cli.py +++ b/cli.py @@ -4460,6 +4460,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_state = None self._clarify_freetext = False self._clarify_deadline = 0 + self._clarify_multi_base = None self._sudo_state = None self._sudo_deadline = 0 self._modal_input_snapshot = None @@ -11895,7 +11896,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): outcome = outcome[:119] + "…" _cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}") - def _clarify_callback(self, question, choices): + def _clarify_callback(self, question, choices, multi_select=False): """ Platform callback for the clarify tool. Called from the agent thread. @@ -11903,6 +11904,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): questions), then blocks until the user responds via the prompt_toolkit key bindings. If no response arrives within the configured timeout the question is dismissed and the agent is told to decide on its own. + + When ``multi_select`` is True, shows checkboxes and the user can + select multiple options with Space, confirming with Enter. """ import time as _time @@ -11913,16 +11917,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): timeout = resolve_clarify_timeout(CLI_CONFIG) response_queue = queue.Queue() is_open_ended = not choices + # multi-select support: only active when multi_select is True and choices exist + effective_multi = multi_select and not is_open_ended self._clarify_state = { "question": question, "choices": choices if not is_open_ended else [], "selected": 0, + # multi-select support + "multi_select": effective_multi, + "selected_indices": set() if effective_multi else None, "response_queue": response_queue, } self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout # Open-ended questions skip straight to freetext input self._clarify_freetext = is_open_ended + self._clarify_multi_base = None # Trigger an immediate prompt_toolkit repaint from this (non-main) # thread. Modal prompts must paint at once and must not be gated by the @@ -11955,6 +11965,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_state = None self._clarify_freetext = False self._clarify_deadline = None + self._clarify_multi_base = None self._paint_now() _cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}") return ( @@ -12377,6 +12388,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): pass self._clarify_state = None self._clarify_freetext = False + self._clarify_multi_base = None if self._sudo_state: try: self._sudo_state["response_queue"].put("") @@ -13926,6 +13938,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if self._clarify_freetext and self._clarify_state: text = event.app.current_buffer.text.strip() if text: + # multi-select: prepend previously checked real choices + base = getattr(self, '_clarify_multi_base', None) + if base: + text = ", ".join(base) + ", " + text + self._clarify_multi_base = None self._clarify_state["response_queue"].put(text) self._clarify_state = None self._clarify_freetext = False @@ -13938,6 +13955,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): state = self._clarify_state selected = state["selected"] choices = state.get("choices") or [] + # multi-select support: submit comma-joined list of checked choices + if state.get("multi_select"): + indices = state.get("selected_indices") + if not indices: + # Nothing checked → submit empty string (parses to []) + state["response_queue"].put("") + self._clarify_state = None + event.app.invalidate() + return + sorted_idx = sorted(indices) + selected_choices = [choices[i] for i in sorted_idx if i < len(choices)] + other_checked = len(choices) in sorted_idx + if other_checked and selected_choices: + # "Other" + real choices: store base choices, switch to freetext + # so the user can type a custom answer that gets appended + self._clarify_multi_base = selected_choices + self._clarify_freetext = True + event.app.invalidate() + return + if selected_choices: + state["response_queue"].put(", ".join(selected_choices)) + self._clarify_state = None + event.app.invalidate() + return + # Only "Other" was checked → switch to freetext + self._clarify_freetext = True + event.app.invalidate() + return + # Original single-select behavior: submit the highlighted choice if selected < len(choices): state["response_queue"].put(choices[selected]) self._clarify_state = None @@ -14172,11 +14218,42 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_state["selected"] = min(max_idx, self._clarify_state["selected"] + 1) event.app.invalidate() + # multi-select support: Space toggles the checkbox at the current cursor position + @kb.add('space', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext and self._clarify_state.get("multi_select"))) + def clarify_toggle(event): + if self._clarify_state: + selected = self._clarify_state["selected"] + indices = self._clarify_state.get("selected_indices", set()) + if selected in indices: + indices.discard(selected) + else: + indices.add(selected) + event.app.invalidate() + # Number keys for quick clarify selection (1-9, 0 for 10th item) def _make_clarify_number_handler(idx): def handler(event): if self._clarify_state and not self._clarify_freetext: choices = self._clarify_state.get("choices") or [] + # multi-select support: number keys toggle checkboxes instead of submitting + if self._clarify_state.get("multi_select"): + if idx < len(choices): + indices = self._clarify_state.get("selected_indices", set()) + if idx in indices: + indices.discard(idx) + else: + indices.add(idx) + event.app.invalidate() + elif idx == len(choices): + # Toggle "Other" in multi-select mode + indices = self._clarify_state.get("selected_indices", set()) + if idx in indices: + indices.discard(idx) + else: + indices.add(idx) + event.app.invalidate() + return + # Original single-select: number keys submit directly # Map index to choice (treating "Other" as the last option) if idx < len(choices): # Select a numbered choice @@ -15069,6 +15146,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): question = state["question"] choices = state.get("choices") or [] selected = state.get("selected", 0) + # multi-select support + multi_select = state.get("multi_select", False) + selected_indices = state.get("selected_indices", set()) if multi_select else set() preview_lines = _wrap_panel_text(question, 60) for i, choice in enumerate(choices): # Show number prefix for quick selection (1-9 for items 1-9, 0 for 10th item) @@ -15078,7 +15158,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): num_prefix = '0' else: num_prefix = ' ' - if i == selected and not cli_ref._clarify_freetext: + if multi_select: + cb = "[x]" if i in selected_indices else "[ ]" + if i == selected and not cli_ref._clarify_freetext: + prefix = f"❯ {cb} {num_prefix}. " + else: + prefix = f" {cb} {num_prefix}. " + elif i == selected and not cli_ref._clarify_freetext: prefix = f"❯ {num_prefix}. " else: prefix = f" {num_prefix}. " @@ -15091,11 +15177,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): other_num_prefix = '0' else: other_num_prefix = ' ' - other_label = ( - f"❯ {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext - else f"❯ {other_num_prefix}. Other (type your answer)" if selected == len(choices) - else f" {other_num_prefix}. Other (type your answer)" - ) + other_idx_val = len(choices) + if multi_select: + cb = "[x]" if other_idx_val in selected_indices else "[ ]" + other_label = ( + f"❯ {cb} {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext + else f"❯ {cb} {other_num_prefix}. Other (type your answer)" if selected == other_idx_val + else f" {cb} {other_num_prefix}. Other (type your answer)" + ) + else: + other_label = ( + f"❯ {other_num_prefix}. Other (type below)" if cli_ref._clarify_freetext + else f"❯ {other_num_prefix}. Other (type your answer)" if selected == len(choices) + else f" {other_num_prefix}. Other (type your answer)" + ) preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" ")) box_width = _panel_box_width("Hermes needs your input", preview_lines) inner_text_width = max(8, box_width - 2) @@ -15111,7 +15206,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): num_prefix = '0' else: num_prefix = ' ' - if i == selected and not cli_ref._clarify_freetext: + # multi-select support: add checkbox after cursor indicator + if multi_select: + cb = "[x]" if i in selected_indices else "[ ]" + if i == selected and not cli_ref._clarify_freetext: + prefix = f'❯ {cb} {num_prefix}. ' + else: + prefix = f' {cb} {num_prefix}. ' + elif i == selected and not cli_ref._clarify_freetext: prefix = f'❯ {num_prefix}. ' else: prefix = f' {num_prefix}. ' @@ -15126,12 +15228,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): other_num_prefix = '0' else: other_num_prefix = ' ' - if selected == other_idx and not cli_ref._clarify_freetext: - other_label_mand = f'❯ {other_num_prefix}. Other (type your answer)' - elif cli_ref._clarify_freetext: - other_label_mand = f'❯ {other_num_prefix}. Other (type below)' + # multi-select support: add checkbox to Other option + if multi_select: + cb = "[x]" if other_idx in selected_indices else "[ ]" + if selected == other_idx and not cli_ref._clarify_freetext: + other_label_mand = f'❯ {cb} {other_num_prefix}. Other (type your answer)' + elif cli_ref._clarify_freetext: + other_label_mand = f'❯ {cb} {other_num_prefix}. Other (type below)' + else: + other_label_mand = f' {cb} {other_num_prefix}. Other (type your answer)' else: - other_label_mand = f' {other_num_prefix}. Other (type your answer)' + if selected == other_idx and not cli_ref._clarify_freetext: + other_label_mand = f'❯ {other_num_prefix}. Other (type your answer)' + elif cli_ref._clarify_freetext: + other_label_mand = f'❯ {other_num_prefix}. Other (type below)' + else: + other_label_mand = f' {other_num_prefix}. Other (type your answer)' other_wrapped = _wrap_panel_text(other_label_mand, inner_text_width, subsequent_indent=" ") elif cli_ref._clarify_freetext: # Freetext-only mode: the guidance line takes the place of choices. diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index f28577cf302b..69f4b6d40501 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -15,11 +15,14 @@ from hermes_cli.secret_prompt import masked_secret_prompt from hermes_constants import display_hermes_home -def clarify_callback(cli, question, choices): +def clarify_callback(cli, question, choices, multi_select=False): """Prompt for clarifying question through the TUI. Sets up the interactive selection UI, then blocks until the user responds. Returns the user's choice or a timeout message. + + When ``multi_select`` is True, shows checkboxes and the user can + select multiple options with Space, confirming with Enter. """ from cli import CLI_CONFIG from tools.clarify_gateway import resolve_clarify_timeout @@ -29,11 +32,14 @@ def clarify_callback(cli, question, choices): timeout = resolve_clarify_timeout(CLI_CONFIG) response_queue = queue.Queue() is_open_ended = not choices + effective_multi = multi_select and not is_open_ended cli._clarify_state = { "question": question, "choices": choices if not is_open_ended else [], "selected": 0, + "multi_select": effective_multi, + "selected_indices": set() if effective_multi else None, "response_queue": response_queue, } cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 320c61c8cc12..1f04173f0a6b 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -469,10 +469,15 @@ def _run_agent( logging.debug("oneshot session store cleanup failed", exc_info=True) -def _oneshot_clarify_callback(question: str, choices=None) -> str: +def _oneshot_clarify_callback(question: str, choices=None, multi_select=False) -> str: """Clarify is disabled in oneshot mode — tell the agent to pick a default and proceed instead of stalling or erroring.""" if choices: + if multi_select: + return ( + f"[oneshot mode: no user available. Pick the best subset from " + f"{choices} using your own judgment and continue.]" + ) return ( f"[oneshot mode: no user available. Pick the best option from " f"{choices} using your own judgment and continue.]" diff --git a/tests/tools/test_clarify_tool.py b/tests/tools/test_clarify_tool.py index 0c38961dd8d2..7ad24eefc880 100644 --- a/tests/tools/test_clarify_tool.py +++ b/tests/tools/test_clarify_tool.py @@ -257,3 +257,189 @@ class TestClarifySchema: def test_max_choices_is_four(self): """MAX_CHOICES constant should be 4.""" assert MAX_CHOICES == 4 + + def test_schema_multi_select_optional(self): + """multi_select should not be in required list.""" + assert "multi_select" not in CLARIFY_SCHEMA["parameters"]["required"] + + def test_schema_multi_select_is_boolean(self): + """multi_select should be a boolean parameter.""" + ms_spec = CLARIFY_SCHEMA["parameters"]["properties"].get("multi_select") + assert ms_spec is not None + assert ms_spec["type"] == "boolean" + + def test_schema_multi_select_default_false(self): + """multi_select should default to false (not in required).""" + # The model should treat it as false when omitted + assert "multi_select" not in CLARIFY_SCHEMA["parameters"]["required"] + + +class TestClarifyToolMultiSelect: + """Tests for multi_select (checkbox) support added to clarify_tool.""" + + def test_multi_select_false_keeps_existing_behavior(self): + """When multi_select=False, user_response should be a single string.""" + def mock_callback(question, choices): + return "blue" + + result = json.loads(clarify_tool( + "What color?", + choices=["red", "blue", "green"], + multi_select=False, + callback=mock_callback, + )) + assert result["user_response"] == "blue" + assert isinstance(result["user_response"], str) + + def test_multi_select_true_returns_list(self): + """When multi_select=True, user_response should be a list of strings.""" + def mock_callback(question, choices): + return "red, blue" + + result = json.loads(clarify_tool( + "Which colors?", + choices=["red", "blue", "green"], + multi_select=True, + callback=mock_callback, + )) + assert result["user_response"] == ["red", "blue"] + assert isinstance(result["user_response"], list) + + def test_multi_select_single_choice_still_list(self): + """Even a single selection should be a list when multi_select=True.""" + def mock_callback(question, choices): + return "red" + + result = json.loads(clarify_tool( + "Which color?", + choices=["red", "blue"], + multi_select=True, + callback=mock_callback, + )) + assert result["user_response"] == ["red"] + assert isinstance(result["user_response"], list) + + def test_multi_select_with_json_array_response(self): + """Callback can return a JSON array string for multi-select.""" + def mock_callback(question, choices): + return '["red", "blue"]' + + result = json.loads(clarify_tool( + "Which colors?", + choices=["red", "blue", "green"], + multi_select=True, + callback=mock_callback, + )) + assert result["user_response"] == ["red", "blue"] + + def test_multi_select_no_choices_falls_back_to_single_string(self): + """When choices is None, multi_select has no effect on response type.""" + def mock_callback(question, choices): + return "free form answer" + + result = json.loads(clarify_tool( + "What do you think?", + multi_select=True, + callback=mock_callback, + )) + # Without choices, falls back to single string response + assert result["user_response"] == "free form answer" + assert isinstance(result["user_response"], str) + + def test_multi_select_default_is_false(self): + """Default multi_select should be False (backward compatible).""" + def mock_callback(question, choices): + return "picked" + + result = json.loads(clarify_tool( + "Pick one", + choices=["a", "b"], + callback=mock_callback, + )) + assert result["user_response"] == "picked" + assert isinstance(result["user_response"], str) + + def test_multi_select_callback_receives_flag(self): + """Callback should receive multi_select keyword argument when supported.""" + received_flag = [] + + def mock_callback(question, choices, **kwargs): + received_flag.append(kwargs.get("multi_select")) + return "a, b" + + clarify_tool( + "Pick", + choices=["a", "b", "c"], + multi_select=True, + callback=mock_callback, + ) + assert received_flag == [True] + + def test_multi_select_backward_compatible_callback(self): + """Callback that does not accept multi_select keyword should still work.""" + def mock_callback(question, choices): + return "a, b" + + result = json.loads(clarify_tool( + "Pick", + choices=["a", "b", "c"], + multi_select=True, + callback=mock_callback, + )) + assert result["user_response"] == ["a", "b"] + + def test_multi_select_empty_selection_returns_empty_list(self): + """Empty response should produce empty list when multi_select=True.""" + def mock_callback(question, choices): + return "" + + result = json.loads(clarify_tool( + "Which?", + choices=["a", "b"], + multi_select=True, + callback=mock_callback, + )) + assert result["user_response"] == [] + + def test_multi_select_whitespace_choices_stripped(self): + """Individual selections should be stripped of whitespace.""" + def mock_callback(question, choices): + return " a , b , c " + + result = json.loads(clarify_tool( + "Which?", + choices=["a", "b", "c"], + multi_select=True, + callback=mock_callback, + )) + assert result["user_response"] == ["a", "b", "c"] + + def test_multi_select_choices_offered_preserved(self): + """choices_offered should match what was passed in, not the response.""" + def mock_callback(question, choices): + return "red, blue" + + result = json.loads(clarify_tool( + "Which?", + choices=["red", "blue", "green"], + multi_select=True, + callback=mock_callback, + )) + assert result["choices_offered"] == ["red", "blue", "green"] + + def test_multi_select_max_choices_enforced(self): + """MAX_CHOICES enforcement should still work with multi_select.""" + choices_passed = [] + + def mock_callback(question, choices): + choices_passed.extend(choices or []) + return "a, b, c, d" + + many_choices = ["a", "b", "c", "d", "e", "f"] + clarify_tool( + "Pick some", + choices=many_choices, + multi_select=True, + callback=mock_callback, + ) + assert len(choices_passed) == MAX_CHOICES diff --git a/tools/clarify_tool.py b/tools/clarify_tool.py index e831d38fb4d6..ebacdcb94d4f 100644 --- a/tools/clarify_tool.py +++ b/tools/clarify_tool.py @@ -6,6 +6,9 @@ Allows the agent to present structured multiple-choice questions or open-ended prompts to the user. In CLI mode, choices are navigable with arrow keys. On messaging platforms, choices are rendered as a numbered list. +Supports both single-select (radio) and multi-select (checkbox) modes via the +``multi_select`` parameter. + The actual user-interaction logic lives in the platform layer (cli.py for CLI, gateway/run.py for messaging). This module defines the schema, validation, and a thin dispatcher that delegates to a platform-provided callback. @@ -53,21 +56,64 @@ def _flatten_choice(c) -> str: return str(c).strip() +def _invoke_callback(callback, question, choices, multi_select): + """Invoke the platform callback, passing multi_select if supported.""" + try: + return callback(question, choices, multi_select=multi_select) + except TypeError: + # Callback does not accept the multi_select keyword; fall back + return callback(question, choices) + + +def _parse_multi_select_response(raw_response) -> List[str]: + """Parse a multi-select response into a list of cleaned choice strings. + + Handles three forms: + - Already a list → stringify + strip each element + - JSON array → parse and strip + - Comma-separated → split, strip, drop empties + """ + if isinstance(raw_response, list): + return [str(r).strip() for r in raw_response if str(r).strip()] + + raw = str(raw_response).strip() + + # Try JSON array + if raw.startswith("["): + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(p).strip() for p in parsed if str(p).strip()] + except json.JSONDecodeError: + pass + + # Fall back to comma-separated + return [s.strip() for s in raw.split(",") if s.strip()] + + def clarify_tool( question: str, choices: Optional[List[str]] = None, + multi_select: bool = False, callback: Optional[Callable] = None, ) -> str: """ Ask the user a question, optionally with multiple-choice options. Args: - question: The question text to present. - choices: Up to 4 predefined answer choices. When omitted the - question is purely open-ended. - callback: Platform-provided function that handles the actual UI - interaction. Signature: callback(question, choices) -> str. - Injected by the agent runner (cli.py / gateway). + question: The question text to present. + choices: Up to 4 predefined answer choices. When omitted the + question is purely open-ended. + multi_select: When True, the user can select multiple choices + (checkboxes). The ``user_response`` in the output JSON + will be a list of strings instead of a single string. + Has no effect when ``choices`` is omitted. + callback: Platform-provided function that handles the actual UI + interaction. Signature: + ``callback(question, choices, multi_select=False) -> str``. + The optional ``multi_select`` keyword is passed so the + platform can render checkboxes instead of radio buttons. + Injected by the agent runner (cli.py / gateway). Returns: JSON string with the user's response. @@ -99,17 +145,22 @@ def clarify_tool( ) try: - user_response = callback(question, choices) + raw_response = _invoke_callback(callback, question, choices, multi_select) except Exception as exc: return json.dumps( {"error": f"Failed to get user input: {exc}"}, ensure_ascii=False, ) + if multi_select and choices is not None: + user_response = _parse_multi_select_response(raw_response) + else: + user_response = str(raw_response).strip() + return json.dumps({ "question": question, "choices_offered": choices, - "user_response": str(user_response).strip(), + "user_response": user_response, }, ensure_ascii=False) @@ -126,10 +177,12 @@ CLARIFY_SCHEMA = { "name": "clarify", "description": ( "Ask the user a question when you need clarification, feedback, or a " - "decision before proceeding. Supports two modes:\n\n" - "1. **Multiple choice** — provide up to 4 choices. The user picks one " + "decision before proceeding. Supports three modes:\n\n" + "1. **Single-select multiple choice** — provide up to 4 choices. The user picks one " "or types their own answer via a 5th 'Other' option.\n" - "2. **Open-ended** — omit choices entirely. The user types a free-form " + "2. **Multi-select multiple choice** — set multi_select=true. The user can select " + "multiple options via checkboxes. user_response will be a list of selected choices.\n" + "3. **Open-ended** — omit choices entirely. The user types a free-form " "response.\n\n" "CRITICAL: when you are offering options, put each option ONLY in the " "`choices` array — NEVER enumerate the options inside the `question` " @@ -169,6 +222,15 @@ CLARIFY_SCHEMA = { "entirely ONLY for a genuinely open-ended free-text question." ), }, + "multi_select": { + "type": "boolean", + "description": ( + "When true, the user can select MULTIPLE options (like checkboxes). " + "The user_response will be a list of selected choices. " + "When false (default), single selection (radio). " + "Has no effect when choices is omitted (open-ended question)." + ), + }, }, "required": ["question"], }, @@ -185,6 +247,7 @@ registry.register( handler=lambda args, **kw: clarify_tool( question=args.get("question", ""), choices=args.get("choices"), + multi_select=args.get("multi_select", False), callback=kw.get("callback")), check_fn=check_clarify_requirements, emoji="❓",