From bd37ff9138d30b3e27f617320884222f1fc656b8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:38:31 -0700 Subject: [PATCH] feat(gateway): inline choice pickers for /reasoning and /fast (Telegram, Discord, Matrix) (#65799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare /reasoning and /fast now render a native one-tap picker on picker-capable platforms, with automatic fallback to the text status card everywhere else — parity with the /model picker UX. - gateway/slash_commands.py: generic send_choice_picker capability gate (detected on the adapter type, like send_model_picker); selection and typed arguments flow through one shared application path so they can never diverge; choices built from VALID_REASONING_EFFORTS so future levels appear automatically - telegram: flat inline-keyboard picker (cp: callbacks), authorized users only (same gate as approval buttons) - discord: ChoicePickerView select menu, auth mirrors ExecApprovalView, 2-minute timeout with expiry edit - matrix: reaction-based picker; reaction set extended to 12 slots to fit the full effort ladder + subcommands - locales: picker_title + choice labels in all 16 languages - docs: ADDING_A_PLATFORM.md capability table Closes #61110. --- gateway/platforms/ADDING_A_PLATFORM.md | 1 + gateway/slash_commands.py | 332 +++++++++++++++++-------- locales/af.yaml | 8 + locales/de.yaml | 8 + locales/en.yaml | 8 + locales/es.yaml | 8 + locales/fr.yaml | 8 + locales/ga.yaml | 8 + locales/hu.yaml | 8 + locales/it.yaml | 8 + locales/ja.yaml | 8 + locales/ko.yaml | 8 + locales/pt.yaml | 8 + locales/ru.yaml | 8 + locales/tr.yaml | 8 + locales/uk.yaml | 8 + locales/zh-hant.yaml | 8 + locales/zh.yaml | 8 + plugins/platforms/discord/adapter.py | 141 ++++++++++- plugins/platforms/matrix/adapter.py | 118 +++++++++ plugins/platforms/telegram/adapter.py | 130 ++++++++++ tests/gateway/test_choice_picker.py | 224 +++++++++++++++++ 22 files changed, 970 insertions(+), 104 deletions(-) create mode 100644 tests/gateway/test_choice_picker.py diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md index e3b84fecaebf..50618aa02f3b 100644 --- a/gateway/platforms/ADDING_A_PLATFORM.md +++ b/gateway/platforms/ADDING_A_PLATFORM.md @@ -120,6 +120,7 @@ If your platform supports interactive button/menu messages, implement these for | `send_exec_approval(chat_id, command, session_key, description, ...)` | Render dangerous-command approval as Approve/Deny buttons. Inbound dispatch routes to `tools.approval.resolve_gateway_approval`. | | `send_slash_confirm(chat_id, title, message, session_key, confirm_id, ...)` | Render slash-command confirmations (e.g. `/reload-mcp`) as Once/Always/Cancel buttons. Inbound dispatch routes to `tools.slash_confirm.resolve`. | | `send_model_picker(...)` | Interactive `/model` picker. Used by Telegram and Discord. | +| `send_choice_picker(...)` | Flat single-level picker for finite-choice commands (`/reasoning`, `/fast`). Implemented by Telegram (inline keyboard), Discord (select menu), and Matrix (reactions). Platforms without it fall back to the text status card automatically. | See `gateway/platforms/telegram.py`, `discord.py`, and `whatsapp_cloud.py` for reference implementations. The button-callback id convention (`cl::`, `appr::`, `sc::`) is shared across adapters — match it so the gateway-side resolvers work without modification. diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 845f44aa3663..9223689bd32a 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2704,7 +2704,154 @@ class GatewaySlashCommandsMixin: preview = prompt[:60] + ("..." if len(prompt) > 60 else "") return t("gateway.background.started", preview=preview, task_id=task_id) - async def _handle_reasoning_command(self, event: MessageEvent) -> str: + def _save_gateway_config_key(self, key_path: str, value) -> bool: + """Save a dot-separated key to config.yaml (shared by /reasoning, /fast + and their interactive pickers).""" + import yaml + from gateway.run import _hermes_home + config_path = _hermes_home / "config.yaml" + try: + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + keys = key_path.split(".") + current = user_config + for k in keys[:-1]: + if k not in current or not isinstance(current[k], dict): + current[k] = {} + current = current[k] + current[keys[-1]] = value + atomic_config_write(config_path, user_config) + return True + except Exception as e: + logger.error("Failed to save config key %s: %s", key_path, e) + return False + + def _apply_reasoning_selection( + self, + session_key: str, + platform_key: str, + value: str, + persist_global: bool = False, + ) -> str: + """Apply a /reasoning argument (typed or picked) and return the reply. + + Single application path shared by the typed `/reasoning ` branch + and the interactive choice picker, so both surfaces stay in lockstep + with the canonical parser. + """ + from hermes_constants import parse_reasoning_effort + + value = (value or "").strip().lower() + + # Display toggle (per-platform) + if value in {"show", "on"}: + self._show_reasoning = True + self._save_gateway_config_key( + f"display.platforms.{platform_key}.show_reasoning", True + ) + return t("gateway.reasoning.display_set_on", platform=platform_key) + if value in {"hide", "off"}: + self._show_reasoning = False + self._save_gateway_config_key( + f"display.platforms.{platform_key}.show_reasoning", False + ) + return t("gateway.reasoning.display_set_off", platform=platform_key) + + if value == "reset": + if persist_global: + return t("gateway.reasoning.reset_global_unsupported") + self._set_session_reasoning_override(session_key, None) + self._reasoning_config = self._load_reasoning_config() + self._evict_cached_agent(session_key) + return t("gateway.reasoning.reset_done") + + parsed = parse_reasoning_effort(value) + if parsed is None: + return t("gateway.reasoning.unknown_arg", arg=value) + + self._reasoning_config = parsed + if persist_global: + if self._save_gateway_config_key("agent.reasoning_effort", value): + self._set_session_reasoning_override(session_key, None) + self._evict_cached_agent(session_key) + return t("gateway.reasoning.set_global", effort=value) + self._set_session_reasoning_override(session_key, parsed) + self._evict_cached_agent(session_key) + return t("gateway.reasoning.set_global_save_failed", effort=value) + + self._set_session_reasoning_override(session_key, parsed) + self._evict_cached_agent(session_key) + return t("gateway.reasoning.set_session", effort=value) + + def _reasoning_picker_choices(self, current_effort: str) -> list: + """Build the choice list for the interactive /reasoning picker.""" + from hermes_constants import VALID_REASONING_EFFORTS + + choices = [ + { + "value": "none", + "label": t("gateway.reasoning.choice_none"), + "is_current": current_effort == "none", + } + ] + for level in VALID_REASONING_EFFORTS: + choices.append( + { + "value": level, + "label": level, + "is_current": level == current_effort, + } + ) + choices.extend( + [ + {"value": "reset", "label": t("gateway.reasoning.choice_reset"), "is_current": False}, + {"value": "show", "label": t("gateway.reasoning.choice_show"), "is_current": False}, + {"value": "hide", "label": t("gateway.reasoning.choice_hide"), "is_current": False}, + ] + ) + return choices + + async def _try_send_choice_picker( + self, + event: MessageEvent, + session_key: str, + title: str, + choices: list, + on_choice_selected, + ) -> bool: + """Send an interactive choice picker when the platform supports it. + + Mirrors the `/model` picker gate: the capability is detected on the + adapter *type* (``send_choice_picker``), and a failed send falls back + to the text path (returns False) instead of erroring the command. + """ + adapter = getattr(self, "_adapter_for_source")(event.source) + has_picker = ( + adapter is not None + and getattr(type(adapter), "send_choice_picker", None) is not None + ) + if not has_picker: + return False + try: + metadata = self._thread_metadata_for_source( + event.source, self._reply_anchor_for_event(event) + ) + result = await adapter.send_choice_picker( + chat_id=event.source.chat_id, + title=title, + choices=choices, + session_key=session_key, + on_choice_selected=on_choice_selected, + metadata=metadata, + ) + return bool(getattr(result, "success", False)) + except Exception as e: + logger.warning("send_choice_picker failed, falling back to text: %s", e) + return False + + async def _handle_reasoning_command(self, event: MessageEvent) -> Optional[str]: """Handle /reasoning command — manage reasoning effort and display toggle. Usage: @@ -2715,13 +2862,10 @@ class GatewaySlashCommandsMixin: /reasoning show|on Show model reasoning in responses /reasoning hide|off Hide model reasoning from responses """ - from gateway.run import _hermes_home, _platform_config_key - from hermes_constants import parse_reasoning_effort - import yaml + from gateway.run import _platform_config_key raw_args = event.get_command_args().strip() args, persist_global = self._parse_reasoning_command_args(raw_args) - config_path = _hermes_home / "config.yaml" # Normalize the source (Telegram DM topic recovery) before deriving # the override key so storage matches the key the next message turn # reads — same fix as /model (#30479). @@ -2739,35 +2883,18 @@ class GatewaySlashCommandsMixin: model=_session_model, ) - def _save_config_key(key_path: str, value): - """Save a dot-separated key to config.yaml.""" - try: - user_config = {} - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - user_config = yaml.safe_load(f) or {} - keys = key_path.split(".") - current = user_config - for k in keys[:-1]: - if k not in current or not isinstance(current[k], dict): - current[k] = {} - current = current[k] - current[keys[-1]] = value - atomic_config_write(config_path, user_config) - return True - except Exception as e: - logger.error("Failed to save config key %s: %s", key_path, e) - return False - if not raw_args: # Show current state rc = self._reasoning_config if rc is None: level = t("gateway.reasoning.level_default") + current_effort = "medium" elif rc.get("enabled") is False: level = t("gateway.reasoning.level_disabled") + current_effort = "none" else: level = rc.get("effort", "medium") + current_effort = level display_state = ( t("gateway.reasoning.display_on") if self._show_reasoning @@ -2779,6 +2906,31 @@ class GatewaySlashCommandsMixin: if has_session_override else t("gateway.reasoning.scope_global") ) + + # Interactive picker on platforms that support it (parity with the + # /model picker). Falls through to the text status card otherwise. + _picker_platform_key = _platform_config_key(event.source.platform) + + async def _on_reasoning_choice(_chat_id: str, value: str) -> str: + return self._apply_reasoning_selection( + session_key, _picker_platform_key, value + ) + + picker_sent = await self._try_send_choice_picker( + event, + session_key, + title=t( + "gateway.reasoning.picker_title", + level=level, + scope=scope, + display=display_state, + ), + choices=self._reasoning_picker_choices(current_effort), + on_choice_selected=_on_reasoning_choice, + ) + if picker_sent: + return None # Picker sent — adapter handles the response + return t( "gateway.reasoning.status", level=level, @@ -2786,47 +2938,11 @@ class GatewaySlashCommandsMixin: display=display_state, ) - # Display toggle (per-platform) + # Typed argument path — same applier the picker uses. platform_key = _platform_config_key(event.source.platform) - if args in {"show", "on"}: - self._show_reasoning = True - _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True) - return t("gateway.reasoning.display_set_on", platform=platform_key) - - if args in {"hide", "off"}: - self._show_reasoning = False - _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False) - return t("gateway.reasoning.display_set_off", platform=platform_key) - - # Effort level change - effort = args.strip() - if effort == "reset": - if persist_global: - return t("gateway.reasoning.reset_global_unsupported") - self._set_session_reasoning_override(session_key, None) - self._reasoning_config = self._load_reasoning_config() - self._evict_cached_agent(session_key) - return t("gateway.reasoning.reset_done") - parsed = parse_reasoning_effort(effort) - if parsed is None: - return t( - "gateway.reasoning.unknown_arg", - arg=effort or raw_args.lower(), - ) - - self._reasoning_config = parsed - if persist_global: - if _save_config_key("agent.reasoning_effort", effort): - self._set_session_reasoning_override(session_key, None) - self._evict_cached_agent(session_key) - return t("gateway.reasoning.set_global", effort=effort) - self._set_session_reasoning_override(session_key, parsed) - self._evict_cached_agent(session_key) - return t("gateway.reasoning.set_global_save_failed", effort=effort) - - self._set_session_reasoning_override(session_key, parsed) - self._evict_cached_agent(session_key) - return t("gateway.reasoning.set_session", effort=effort) + return self._apply_reasoning_selection( + session_key, platform_key, args, persist_global=persist_global + ) async def _handle_memory_command(self, event: MessageEvent) -> str: """Handle /memory — review pending memory writes + toggle the approval gate. @@ -2932,14 +3048,12 @@ class GatewaySlashCommandsMixin: f"~/.hermes/pending/skills/{pending_id}.json)") return out - async def _handle_fast_command(self, event: MessageEvent) -> str: + async def _handle_fast_command(self, event: MessageEvent) -> Optional[str]: """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" - from gateway.run import _hermes_home, _load_gateway_config, _resolve_gateway_model - import yaml + from gateway.run import _load_gateway_config, _resolve_gateway_model from hermes_cli.models import model_supports_fast_mode args = event.get_command_args().strip().lower() - config_path = _hermes_home / "config.yaml" self._service_tier = self._load_service_tier() user_config = _load_gateway_config() @@ -2947,44 +3061,56 @@ class GatewaySlashCommandsMixin: if not model_supports_fast_mode(model): return t("gateway.fast.not_supported") - def _save_config_key(key_path: str, value): - """Save a dot-separated key to config.yaml.""" - try: - user_config = {} - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - user_config = yaml.safe_load(f) or {} - keys = key_path.split(".") - current = user_config - for k in keys[:-1]: - if k not in current or not isinstance(current[k], dict): - current[k] = {} - current = current[k] - current[keys[-1]] = value - atomic_config_write(config_path, user_config) - return True - except Exception as e: - logger.error("Failed to save config key %s: %s", key_path, e) - return False + def _apply_fast_selection(value: str) -> str: + """Apply a /fast argument (typed or picked) and return the reply.""" + if value in {"fast", "on"}: + self._service_tier = "priority" + saved_value = "fast" + label = t("gateway.fast.label_fast") + elif value in {"normal", "off"}: + self._service_tier = None + saved_value = "normal" + label = t("gateway.fast.label_normal") + else: + return t("gateway.fast.unknown_arg", arg=value) + if self._save_gateway_config_key("agent.service_tier", saved_value): + return t("gateway.fast.saved", label=label) + return t("gateway.fast.session_only", label=label) if not args or args == "status": - status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal") + is_fast = self._service_tier == "priority" + status = t("gateway.fast.status_fast") if is_fast else t("gateway.fast.status_normal") + + # Interactive picker on platforms that support it. + session_key = self._session_key_for_source(event.source) + + async def _on_fast_choice(_chat_id: str, value: str) -> str: + return _apply_fast_selection(value) + + picker_sent = await self._try_send_choice_picker( + event, + session_key, + title=t("gateway.fast.picker_title", mode=status), + choices=[ + { + "value": "fast", + "label": t("gateway.fast.choice_fast"), + "is_current": is_fast, + }, + { + "value": "normal", + "label": t("gateway.fast.choice_normal"), + "is_current": not is_fast, + }, + ], + on_choice_selected=_on_fast_choice, + ) + if picker_sent: + return None # Picker sent — adapter handles the response + return t("gateway.fast.status", mode=status) - if args in {"fast", "on"}: - self._service_tier = "priority" - saved_value = "fast" - label = t("gateway.fast.label_fast") - elif args in {"normal", "off"}: - self._service_tier = None - saved_value = "normal" - label = t("gateway.fast.label_normal") - else: - return t("gateway.fast.unknown_arg", arg=args) - - if _save_config_key("agent.service_tier", saved_value): - return t("gateway.fast.saved", label=label) - return t("gateway.fast.session_only", label=label) + return _apply_fast_selection(args) async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /yolo — toggle dangerous command approval bypass for this session only.""" diff --git a/locales/af.yaml b/locales/af.yaml index 001fbe79ddcb..6ade25b452e7 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nHuidige modus: `{mode}`\\n\\nKies \\'n opsie:" + choice_fast: "fast — Priority Processing aan" + choice_normal: "normal — standaardverwerking" footer: status: "📎 Looptyd-voetstuk: **{state}**\nVelde: `{fields}`\nPlatform: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` word nie ondersteun nie. Gebruik `/reasoning --global` om die globale verstek te verander." reset_done: "🧠 ✓ Sessie-redenering-oorskryf verwyder; val terug op globale konfigurasie." unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige vlakke:** none, minimal, low, medium, high, xhigh, max, ultra\n**Vertoon:** show, hide\n**Permanent:** voeg `--global` by om verby hierdie sessie te stoor" + picker_title: "🧠 **Redenering-instellings**\\n\\n**Inspanning:** `{level}`\\n**Bereik:** {scope}\\n**Vertoon:** {display}\\n\\nKies \\'n opsie:" + choice_none: "none — deaktiveer redenering" + choice_reset: "reset — vee sessie-oorskrywing uit" + choice_show: "wys redenering in antwoorde" + choice_hide: "versteek redenering in antwoorde" set_global: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (gestoor in konfigurasie)\n_(neem effek by die volgende boodskap)_" set_global_save_failed: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — konfigurasie-stoor het misluk)\n_(neem effek by die volgende boodskap)_" set_session: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — voeg `--global` by om permanent te stoor)\n_(neem effek by die volgende boodskap)_" diff --git a/locales/de.yaml b/locales/de.yaml index c93f1b5070aa..2c6b83d3359a 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nAktueller Modus: `{mode}`\\n\\nOption wählen:" + choice_fast: "fast — Priority Processing an" + choice_normal: "normal — Standardverarbeitung" footer: status: "📎 Laufzeit-Fußzeile: **{state}**\nFelder: `{fields}`\nPlattform: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` wird nicht unterstützt. Verwenden Sie `/reasoning --global`, um den globalen Standard zu ändern." reset_done: "🧠 ✓ Sitzungs-Reasoning-Override gelöscht; Rückfall auf globale Konfiguration." unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Stärken:** none, minimal, low, medium, high, xhigh, max, ultra\n**Anzeige:** show, hide\n**Speichern:** `--global` hinzufügen, um über die Sitzung hinaus zu speichern" + picker_title: "🧠 **Reasoning-Einstellungen**\\n\\n**Stärke:** `{level}`\\n**Geltungsbereich:** {scope}\\n**Anzeige:** {display}\\n\\nOption wählen:" + choice_none: "none — Reasoning deaktivieren" + choice_reset: "reset — Sitzungs-Override löschen" + choice_show: "Reasoning in Antworten anzeigen" + choice_hide: "Reasoning in Antworten ausblenden" set_global: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (in Konfiguration gespeichert)\n_(wird mit der nächsten Nachricht wirksam)_" set_global_save_failed: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — Konfiguration konnte nicht gespeichert werden)\n_(wird mit der nächsten Nachricht wirksam)_" set_session: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — `--global` hinzufügen, um zu speichern)\n_(wird mit der nächsten Nachricht wirksam)_" diff --git a/locales/en.yaml b/locales/en.yaml index 26063363a48a..2bd229e2b52c 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -139,6 +139,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\n\nCurrent mode: `{mode}`\n\nPick an option:" + choice_fast: "fast — Priority Processing on" + choice_normal: "normal — standard processing" footer: status: "📎 Runtime footer: **{state}**\nFields: `{fields}`\nPlatform: `{platform}`" @@ -209,6 +212,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` is not supported. Use `/reasoning --global` to change the global default." reset_done: "🧠 ✓ Session reasoning override cleared; falling back to global config." unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid levels:** none, minimal, low, medium, high, xhigh, max, ultra\n**Display:** show, hide\n**Persist:** add `--global` to save beyond this session" + picker_title: "🧠 **Reasoning Settings**\n\n**Effort:** `{level}`\n**Scope:** {scope}\n**Display:** {display}\n\nPick an option:" + choice_none: "none — disable reasoning" + choice_reset: "reset — clear session override" + choice_show: "show reasoning in replies" + choice_hide: "hide reasoning from replies" set_global: "🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" set_global_save_failed: "🧠 ✓ Reasoning effort set to `{effort}` (session only — config save failed)\n_(takes effect on next message)_" set_session: "🧠 ✓ Reasoning effort set to `{effort}` (session only — add `--global` to persist)\n_(takes effect on next message)_" diff --git a/locales/es.yaml b/locales/es.yaml index 2cba217efff5..61e2b4418c04 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nModo actual: `{mode}`\\n\\nElige una opción:" + choice_fast: "fast — Priority Processing activado" + choice_normal: "normal — procesamiento estándar" footer: status: "📎 Pie de ejecución: **{state}**\nCampos: `{fields}`\nPlataforma: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` no es compatible. Usa `/reasoning --global` para cambiar el valor global por defecto." reset_done: "🧠 ✓ Anulación de razonamiento de la sesión borrada; volviendo a la configuración global." unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Niveles válidos:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualización:** show, hide\n**Persistir:** añade `--global` para guardar más allá de esta sesión" + picker_title: "🧠 **Ajustes de razonamiento**\\n\\n**Esfuerzo:** `{level}`\\n**Alcance:** {scope}\\n**Visualización:** {display}\\n\\nElige una opción:" + choice_none: "none — desactivar razonamiento" + choice_reset: "reset — borrar el ajuste de sesión" + choice_show: "mostrar razonamiento en respuestas" + choice_hide: "ocultar razonamiento en respuestas" set_global: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (guardado en la configuración)\n_(se aplica en el próximo mensaje)_" set_global_save_failed: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — error al guardar la configuración)\n_(se aplica en el próximo mensaje)_" set_session: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — añade `--global` para persistir)\n_(se aplica en el próximo mensaje)_" diff --git a/locales/fr.yaml b/locales/fr.yaml index 71391bcb3618..d067b26eeeae 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nMode actuel : `{mode}`\\n\\nChoisissez une option :" + choice_fast: "fast — Priority Processing activé" + choice_normal: "normal — traitement standard" footer: status: "📎 Pied de page d'exécution : **{state}**\nChamps : `{fields}`\nPlateforme : `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` n'est pas pris en charge. Utilisez `/reasoning --global` pour modifier la valeur globale par défaut." reset_done: "🧠 ✓ Remplacement de raisonnement de la session effacé ; retour à la configuration globale." unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Niveaux valides :** none, minimal, low, medium, high, xhigh, max, ultra\n**Affichage :** show, hide\n**Persister :** ajoutez `--global` pour enregistrer au-delà de cette session" + picker_title: "🧠 **Paramètres de raisonnement**\\n\\n**Effort :** `{level}`\\n**Portée :** {scope}\\n**Affichage :** {display}\\n\\nChoisissez une option :" + choice_none: "none — désactiver le raisonnement" + choice_reset: "reset — effacer le réglage de session" + choice_show: "afficher le raisonnement dans les réponses" + choice_hide: "masquer le raisonnement dans les réponses" set_global: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (enregistré dans la configuration)\n_(prend effet au prochain message)_" set_global_save_failed: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — échec de l'enregistrement de la configuration)\n_(prend effet au prochain message)_" set_session: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — ajoutez `--global` pour persister)\n_(prend effet au prochain message)_" diff --git a/locales/ga.yaml b/locales/ga.yaml index 5cbb73334e59..32ed8d597add 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -128,6 +128,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nMód reatha: `{mode}`\\n\\nRoghnaigh rogha:" + choice_fast: "fast — Priority Processing ar siúl" + choice_normal: "normal — gnáthphróiseáil" footer: status: "📎 Buntásc rite: **{state}**\nRéimsí: `{fields}`\nArdán: `{platform}`" @@ -198,6 +201,11 @@ gateway: reset_global_unsupported: "⚠️ Ní thacaítear le `/reasoning reset --global`. Úsáid `/reasoning --global` chun an réamhshocrú domhanda a athrú." reset_done: "🧠 ✓ Sárú réasúnaíochta seisiúin glanta; ag titim siar ar an gcumraíocht dhomhanda." unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Leibhéil bhailí:** none, minimal, low, medium, high, xhigh, max, ultra\n**Taispeáint:** show, hide\n**Coinnigh:** cuir `--global` leis chun sábháil thar an seisiún seo" + picker_title: "🧠 **Socruithe Réasúnaíochta**\\n\\n**Iarracht:** `{level}`\\n**Scóip:** {scope}\\n**Taispeáint:** {display}\\n\\nRoghnaigh rogha:" + choice_none: "none — díchumasaigh réasúnaíocht" + choice_reset: "reset — glan sárú an tseisiúin" + choice_show: "taispeáin réasúnaíocht sna freagraí" + choice_hide: "folaigh réasúnaíocht ó na freagraí" set_global: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (sábháilte sa chumraíocht)\n_(éifeachtach ón gcéad teachtaireacht eile)_" set_global_save_failed: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — theip ar shábháil cumraíochta)\n_(éifeachtach ón gcéad teachtaireacht eile)_" set_session: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — cuir `--global` leis chun é a choinneáil)\n_(éifeachtach ón gcéad teachtaireacht eile)_" diff --git a/locales/hu.yaml b/locales/hu.yaml index 7932a72792ac..97a2e675155f 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nJelenlegi mód: `{mode}`\\n\\nVálassz egy opciót:" + choice_fast: "fast — Priority Processing bekapcsolva" + choice_normal: "normal — normál feldolgozás" footer: status: "📎 Futási idejű lábléc: **{state}**\nMezők: `{fields}`\nPlatform: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ A `/reasoning reset --global` nem támogatott. Használd a `/reasoning --global` parancsot a globális alapérték módosításához." reset_done: "🧠 ✓ A munkamenet gondolkodási felülbírálása törölve; visszaállás a globális konfigurációra." unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes szintek:** none, minimal, low, medium, high, xhigh, max, ultra\n**Megjelenítés:** show, hide\n**Megőrzés:** add hozzá a `--global` opciót a munkameneten túli mentéshez" + picker_title: "🧠 **Gondolkodási beállítások**\\n\\n**Erőfeszítés:** `{level}`\\n**Hatókör:** {scope}\\n**Megjelenítés:** {display}\\n\\nVálassz egy opciót:" + choice_none: "none — gondolkodás kikapcsolása" + choice_reset: "reset — munkameneti felülbírálás törlése" + choice_show: "gondolkodás megjelenítése a válaszokban" + choice_hide: "gondolkodás elrejtése a válaszokból" set_global: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (mentve a konfigurációba)\n_(a következő üzenettől lép életbe)_" set_global_save_failed: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — a konfiguráció mentése sikertelen)\n_(a következő üzenettől lép életbe)_" set_session: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — add hozzá a `--global` opciót a megőrzéshez)\n_(a következő üzenettől lép életbe)_" diff --git a/locales/it.yaml b/locales/it.yaml index 8e89bcc1d64b..f390f2879c69 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nModalità attuale: `{mode}`\\n\\nScegli un\\'opzione:" + choice_fast: "fast — Priority Processing attivo" + choice_normal: "normal — elaborazione standard" footer: status: "📎 Footer di runtime: **{state}**\nCampi: `{fields}`\nPiattaforma: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` non è supportato. Usa `/reasoning --global` per cambiare il valore predefinito globale." reset_done: "🧠 ✓ Override di reasoning della sessione cancellato; ripristino della configurazione globale." unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Livelli validi:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualizzazione:** show, hide\n**Persistenza:** aggiungi `--global` per salvare oltre questa sessione" + picker_title: "🧠 **Impostazioni di reasoning**\\n\\n**Sforzo:** `{level}`\\n**Ambito:** {scope}\\n**Visualizzazione:** {display}\\n\\nScegli un\\'opzione:" + choice_none: "none — disattiva il reasoning" + choice_reset: "reset — cancella l'override di sessione" + choice_show: "mostra il reasoning nelle risposte" + choice_hide: "nascondi il reasoning dalle risposte" set_global: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (salvato nella configurazione)\n_(verrà applicato al prossimo messaggio)_" set_global_save_failed: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — salvataggio della configurazione non riuscito)\n_(verrà applicato al prossimo messaggio)_" set_session: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — aggiungi `--global` per renderlo permanente)\n_(verrà applicato al prossimo messaggio)_" diff --git a/locales/ja.yaml b/locales/ja.yaml index 250ae856f538..2682dc586356 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\n現在のモード: `{mode}`\\n\\nオプションを選択:" + choice_fast: "fast — Priority Processing オン" + choice_normal: "normal — 標準処理" footer: status: "📎 ランタイムフッター: **{state}**\nフィールド: `{fields}`\nプラットフォーム: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` はサポートされていません。グローバルのデフォルトを変更するには `/reasoning --global` を使用してください。" reset_done: "🧠 ✓ セッションの推論オーバーライドをクリアしました。グローバル設定にフォールバックします。" unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なレベル:** none, minimal, low, medium, high, xhigh, max, ultra\n**表示:** show, hide\n**永続化:** セッションを越えて保存するには `--global` を追加" + picker_title: "🧠 **推論設定**\\n\\n**強度:** `{level}`\\n**スコープ:** {scope}\\n**表示:** {display}\\n\\nオプションを選択:" + choice_none: "none — 推論を無効化" + choice_reset: "reset — セッション上書きをクリア" + choice_show: "返信に推論を表示" + choice_hide: "返信の推論を非表示" set_global: "🧠 ✓ 推論強度を `{effort}` に設定しました (設定に保存)\n_(次のメッセージから有効)_" set_global_save_failed: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 設定の保存に失敗)\n_(次のメッセージから有効)_" set_session: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 永続化するには `--global` を追加)\n_(次のメッセージから有効)_" diff --git a/locales/ko.yaml b/locales/ko.yaml index 602b2afafadc..df07aa12a380 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\n현재 모드: `{mode}`\\n\\n옵션을 선택하세요:" + choice_fast: "fast — Priority Processing 켜기" + choice_normal: "normal — 표준 처리" footer: status: "📎 런타임 푸터: **{state}**\n필드: `{fields}`\n플랫폼: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global`은 지원되지 않습니다. 전역 기본값을 변경하려면 `/reasoning --global`을 사용하세요." reset_done: "🧠 ✓ 세션 추론 재정의가 해제되었습니다. 전역 설정으로 돌아갑니다." unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 수준:** none, minimal, low, medium, high, xhigh, max, ultra\n**표시:** show, hide\n**영구화:** 이 세션을 넘어 저장하려면 `--global`을 추가하세요" + picker_title: "🧠 **추론 설정**\\n\\n**노력:** `{level}`\\n**범위:** {scope}\\n**표시:** {display}\\n\\n옵션을 선택하세요:" + choice_none: "none — 추론 비활성화" + choice_reset: "reset — 세션 재정의 초기화" + choice_show: "응답에 추론 표시" + choice_hide: "응답에서 추론 숨기기" set_global: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (설정에 저장됨)\n_(다음 메시지부터 적용됩니다)_" set_global_save_failed: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 설정 저장 실패)\n_(다음 메시지부터 적용됩니다)_" set_session: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 영구 저장하려면 `--global` 추가)\n_(다음 메시지부터 적용됩니다)_" diff --git a/locales/pt.yaml b/locales/pt.yaml index 31617d9bee9c..3559c044c549 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nModo atual: `{mode}`\\n\\nEscolha uma opção:" + choice_fast: "fast — Priority Processing ativado" + choice_normal: "normal — processamento padrão" footer: status: "📎 Rodapé de execução: **{state}**\nCampos: `{fields}`\nPlataforma: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` não é suportado. Usa `/reasoning --global` para alterar o predefinido global." reset_done: "🧠 ✓ Substituição de raciocínio da sessão removida; a regressar à configuração global." unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Níveis válidos:** none, minimal, low, medium, high, xhigh, max, ultra\n**Visualização:** show, hide\n**Persistir:** adiciona `--global` para guardar para além desta sessão" + picker_title: "🧠 **Definições de raciocínio**\\n\\n**Esforço:** `{level}`\\n**Âmbito:** {scope}\\n**Visualização:** {display}\\n\\nEscolha uma opção:" + choice_none: "none — desativar raciocínio" + choice_reset: "reset — limpar o override de sessão" + choice_show: "mostrar raciocínio nas respostas" + choice_hide: "ocultar raciocínio das respostas" set_global: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (guardado na configuração)\n_(produz efeito na próxima mensagem)_" set_global_save_failed: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — falha ao guardar a configuração)\n_(produz efeito na próxima mensagem)_" set_session: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — adiciona `--global` para persistir)\n_(produz efeito na próxima mensagem)_" diff --git a/locales/ru.yaml b/locales/ru.yaml index 80538118e0a5..b55c006244bb 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nТекущий режим: `{mode}`\\n\\nВыберите вариант:" + choice_fast: "fast — Priority Processing включён" + choice_normal: "normal — стандартная обработка" footer: status: "📎 Нижний колонтитул среды выполнения: **{state}**\nПоля: `{fields}`\nПлатформа: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` не поддерживается. Используйте `/reasoning --global`, чтобы изменить глобальное значение по умолчанию." reset_done: "🧠 ✓ Переопределение рассуждений для сеанса сброшено; возврат к глобальной конфигурации." unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые уровни:** none, minimal, low, medium, high, xhigh, max, ultra\n**Отображение:** show, hide\n**Сохранение:** добавьте `--global`, чтобы сохранить за пределами этого сеанса" + picker_title: "🧠 **Настройки рассуждений**\\n\\n**Усилия:** `{level}`\\n**Область:** {scope}\\n**Отображение:** {display}\\n\\nВыберите вариант:" + choice_none: "none — отключить рассуждения" + choice_reset: "reset — сбросить переопределение сессии" + choice_show: "показывать рассуждения в ответах" + choice_hide: "скрывать рассуждения в ответах" set_global: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (сохранено в конфигурации)\n_(вступит в силу со следующего сообщения)_" set_global_save_failed: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — не удалось сохранить конфигурацию)\n_(вступит в силу со следующего сообщения)_" set_session: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — добавьте `--global`, чтобы сохранить)\n_(вступит в силу со следующего сообщения)_" diff --git a/locales/tr.yaml b/locales/tr.yaml index 21ffa8025986..5e5f0a151755 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nMevcut mod: `{mode}`\\n\\nBir seçenek seçin:" + choice_fast: "fast — Priority Processing açık" + choice_normal: "normal — standart işleme" footer: status: "📎 Çalışma zamanı altbilgisi: **{state}**\nAlanlar: `{fields}`\nPlatform: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` desteklenmiyor. Genel varsayılanı değiştirmek için `/reasoning --global` kullanın." reset_done: "🧠 ✓ Oturumun akıl yürütme geçersiz kılması temizlendi; genel yapılandırmaya geri dönülüyor." unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seviyeler:** none, minimal, low, medium, high, xhigh, max, ultra\n**Görüntüleme:** show, hide\n**Kalıcı:** bu oturumun ötesinde kaydetmek için `--global` ekleyin" + picker_title: "🧠 **Akıl Yürütme Ayarları**\\n\\n**Güç:** `{level}`\\n**Kapsam:** {scope}\\n**Görüntüleme:** {display}\\n\\nBir seçenek seçin:" + choice_none: "none — akıl yürütmeyi kapat" + choice_reset: "reset — oturum geçersiz kılmasını temizle" + choice_show: "yanıtlarda akıl yürütmeyi göster" + choice_hide: "yanıtlarda akıl yürütmeyi gizle" set_global: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yapılandırmaya kaydedildi)\n_(sonraki mesajda etkili)_" set_global_save_failed: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — yapılandırma kaydedilemedi)\n_(sonraki mesajda etkili)_" set_session: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — kalıcı yapmak için `--global` ekleyin)\n_(sonraki mesajda etkili)_" diff --git a/locales/uk.yaml b/locales/uk.yaml index dbc72263ad6c..4606ebe91a5f 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\nПоточний режим: `{mode}`\\n\\nОберіть варіант:" + choice_fast: "fast — Priority Processing увімкнено" + choice_normal: "normal — стандартна обробка" footer: status: "📎 Нижній колонтитул середовища: **{state}**\nПоля: `{fields}`\nПлатформа: `{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ `/reasoning reset --global` не підтримується. Використовуйте `/reasoning --global`, щоб змінити глобальне значення за замовчуванням." reset_done: "🧠 ✓ Перевизначення мислення для сеансу скинуто; повернення до глобальної конфігурації." unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Дійсні рівні:** none, minimal, low, medium, high, xhigh, max, ultra\n**Показ:** show, hide\n**Зберегти:** додайте `--global`, щоб зберегти поза цим сеансом" + picker_title: "🧠 **Налаштування мислення**\\n\\n**Зусилля:** `{level}`\\n**Область:** {scope}\\n**Показ:** {display}\\n\\nОберіть варіант:" + choice_none: "none — вимкнути мислення" + choice_reset: "reset — скинути перевизначення сесії" + choice_show: "показувати мислення у відповідях" + choice_hide: "приховувати мислення у відповідях" set_global: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (збережено в конфігурації)\n_(набуде чинності з наступного повідомлення)_" set_global_save_failed: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — не вдалося зберегти конфігурацію)\n_(набуде чинності з наступного повідомлення)_" set_session: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — додайте `--global`, щоб зберегти)\n_(набуде чинності з наступного повідомлення)_" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 91ada966a17b..39b7079a7e3d 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **Priority Processing**\\n\\n目前模式:`{mode}`\\n\\n請選擇:" + choice_fast: "fast — 開啟 Priority Processing" + choice_normal: "normal — 標準處理" footer: status: "📎 執行階段頁尾:**{state}**\n欄位:`{fields}`\n平台:`{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ 不支援 `/reasoning reset --global`。請使用 `/reasoning --global` 變更全域預設值。" reset_done: "🧠 ✓ 已清除本工作階段的推理覆寫;回退至全域設定。" unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效級別:** none, minimal, low, medium, high, xhigh, max, ultra\n**顯示:** show, hide\n**持久化:** 加上 `--global` 可跨工作階段儲存" + picker_title: "🧠 **推理設定**\\n\\n**強度:** `{level}`\\n**範圍:** {scope}\\n**顯示:** {display}\\n\\n請選擇:" + choice_none: "none — 停用推理" + choice_reset: "reset — 清除工作階段覆寫" + choice_show: "在回覆中顯示推理" + choice_hide: "在回覆中隱藏推理" set_global: "🧠 ✓ 推理強度已設定為 `{effort}`(已儲存到設定)\n_(下一則訊息生效)_" set_global_save_failed: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 設定儲存失敗)\n_(下一則訊息生效)_" set_session: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 加上 `--global` 可持久化)\n_(下一則訊息生效)_" diff --git a/locales/zh.yaml b/locales/zh.yaml index 24b3c1536b90..7cf13776a678 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -124,6 +124,9 @@ gateway: label_normal: "NORMAL" status_fast: "fast" status_normal: "normal" + picker_title: "⚡ **优先处理**\\n\\n当前模式:`{mode}`\\n\\n请选择:" + choice_fast: "fast — 开启优先处理" + choice_normal: "normal — 标准处理" footer: status: "📎 运行时页脚:**{state}**\n字段:`{fields}`\n平台:`{platform}`" @@ -194,6 +197,11 @@ gateway: reset_global_unsupported: "⚠️ 不支持 `/reasoning reset --global`。请使用 `/reasoning --global` 修改全局默认值。" reset_done: "🧠 ✓ 已清除本会话的推理覆盖;回退到全局配置。" unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效级别:** none, minimal, low, medium, high, xhigh, max, ultra\n**显示:** show, hide\n**持久化:** 添加 `--global` 以跨会话保存" + picker_title: "🧠 **推理设置**\\n\\n**强度:** `{level}`\\n**作用域:** {scope}\\n**显示:** {display}\\n\\n请选择:" + choice_none: "none — 关闭推理" + choice_reset: "reset — 清除会话覆盖" + choice_show: "在回复中显示推理" + choice_hide: "在回复中隐藏推理" set_global: "🧠 ✓ 推理强度已设置为 `{effort}`(已保存到配置)\n_(下一条消息生效)_" set_global_save_failed: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 配置保存失败)\n_(下一条消息生效)_" set_session: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 添加 `--global` 以持久化)\n_(下一条消息生效)_" diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 7372b4181330..8257ccbe4a8d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -5961,6 +5961,54 @@ class DiscordAdapter(BasePlatformAdapter): logger.warning("[%s] send_model_picker failed: %s", self.name, e) return SendResult(success=False, error=str(e)) + async def send_choice_picker( + self, + chat_id: str, + title: str, + choices: list, + session_key: str, + on_choice_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a flat select-menu choice picker (one selection → one value). + + Generic single-level companion to ``send_model_picker`` used by + `/reasoning`, `/fast`, and any future finite-choice command. Each + choice dict: ``{"value": str, "label": str, "is_current": bool}``. + """ + if not self._client or not DISCORD_AVAILABLE: + return SendResult(success=False, error="Not connected") + + try: + target_id = chat_id + if metadata and metadata.get("thread_id"): + target_id = metadata["thread_id"] + + channel = self._client.get_channel(int(target_id)) + if not channel: + channel = await self._client.fetch_channel(int(target_id)) + + embed = discord.Embed( + title="⚙ " + (title.splitlines()[0] if title else "Choose an option"), + description="\n".join(title.splitlines()[1:]) or None, + color=discord.Color.blue(), + ) + + view = ChoicePickerView( + choices=choices, + on_choice_selected=on_choice_selected, + allowed_user_ids=self._allowed_user_ids, + allowed_role_ids=self._allowed_role_ids, + ) + + msg = await channel.send(embed=embed, view=view) + view._message = msg # store for on_timeout expiration editing + return SendResult(success=True, message_id=str(msg.id)) + + except Exception as e: + logger.warning("[%s] send_choice_picker failed: %s", self.name, e) + return SendResult(success=False, error=str(e)) + def _get_parent_channel_id(self, channel: Any) -> Optional[str]: """Return the parent channel ID for a Discord thread-like channel, if present.""" parent = getattr(channel, "parent", None) @@ -6850,7 +6898,7 @@ def _define_discord_view_classes() -> None: lazy install sets DISCORD_AVAILABLE=True but leaves the classes undefined, causing NameError on the first button interaction. """ - global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView + global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView, ChoicePickerView class ExecApprovalView(discord.ui.View): """ @@ -7547,6 +7595,97 @@ def _define_discord_view_classes() -> None: pass + class ChoicePickerView(discord.ui.View): + """Flat select-menu view for finite-choice commands (/reasoning, /fast). + + One dropdown, one selection, done — the generic single-level companion + to ``ModelPickerView``. Auth gating mirrors ``ExecApprovalView``. + Times out after 2 minutes. + """ + + def __init__( + self, + choices: list, + on_choice_selected, + allowed_user_ids: set, + allowed_role_ids: Optional[set] = None, + ): + super().__init__(timeout=120) + self.choices = list(choices)[:25] # Discord select cap + self.on_choice_selected = on_choice_selected + self.allowed_user_ids = allowed_user_ids + self.allowed_role_ids = allowed_role_ids or set() + self.resolved = False + self._message = None + + options = [] + for choice in self.choices: + label = str(choice.get("label") or choice.get("value") or "") + options.append( + discord.SelectOption( + label=_truncate_discord_component_text( + label, _DISCORD_SELECT_FIELD_LIMIT + ), + value=str(choice.get("value") or ""), + description="current" if choice.get("is_current") else None, + ) + ) + select = discord.ui.Select( + placeholder="Choose an option...", + options=options, + ) + select.callback = self._on_select + self.add_item(select) + + def _check_auth(self, interaction: discord.Interaction) -> bool: + return _component_check_auth( + interaction, self.allowed_user_ids, self.allowed_role_ids, + ) + + async def _on_select(self, interaction: discord.Interaction): + if not self._check_auth(interaction): + await interaction.response.send_message( + "⛔ You are not authorized to change this setting.", + ephemeral=True, + ) + return + if self.resolved: + await interaction.response.defer() + return + self.resolved = True + + value = interaction.data.get("values", [""])[0] + try: + result_text = await self.on_choice_selected( + str(interaction.channel_id), value + ) + except Exception as exc: + logger.error("Choice picker selection failed: %s", exc) + result_text = f"Error applying selection: {exc}" + + embed = discord.Embed( + description=result_text, + color=discord.Color.green(), + ) + self.clear_items() + self.stop() + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self): + if self.resolved: + return + msg = self._message + if msg is not None: + try: + embed = discord.Embed( + description="⏱ Selection expired — no change made.", + color=discord.Color.greyple(), + ) + self.clear_items() + await msg.edit(embed=embed, view=self) + except Exception: + pass + class ClarifyChoiceView(discord.ui.View): """Interactive button view for the clarify tool's multiple-choice prompts. diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index f653528d067d..3835d8e31efa 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -337,6 +337,21 @@ class _MatrixModelPickerPrompt: bot_reaction_events: dict[str, str] = field(default_factory=dict) +@dataclass +class _MatrixChoicePickerPrompt: + """Tracks a pending Matrix reaction-based choice picker (/reasoning, /fast).""" + + chat_id: str + message_id: str + session_key: str + choices: dict[str, str] # emoji -> value + on_choice_selected: Any + requester_user_id: str | None = None + expires_at: float | None = None + resolved: bool = False + bot_reaction_events: dict[str, str] = field(default_factory=dict) + + # Matrix message size limit (4000 chars practical, spec has no hard limit # but clients render poorly above this). MAX_MESSAGE_LENGTH = 4000 @@ -386,6 +401,14 @@ _MATRIX_MODEL_PICKER_REACTIONS = ( "\U0001f51f", ) +# Choice pickers (/reasoning, /fast) can need more than 10 slots +# (8 effort levels + none + reset/show/hide = 12), so extend the keycap +# set with lettered squares. +_MATRIX_CHOICE_PICKER_REACTIONS = _MATRIX_MODEL_PICKER_REACTIONS + ( + "\U0001f170\ufe0f", # 🅰️ + "\U0001f171\ufe0f", # 🅱️ +) + _MATRIX_CAPABILITIES: Dict[str, str] = { "text": "yes", "threads": "yes", @@ -954,6 +977,7 @@ class MatrixAdapter(BasePlatformAdapter): except ValueError: self._approval_timeout_seconds = 300 self._model_picker_prompts_by_event: Dict[str, _MatrixModelPickerPrompt] = {} + self._choice_picker_prompts_by_event: Dict[str, _MatrixChoicePickerPrompt] = {} allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "") self._allowed_user_ids: Set[str] = { u.strip() for u in allowed_users_raw.split(",") if u.strip() @@ -2136,6 +2160,66 @@ class MatrixAdapter(BasePlatformAdapter): return result + async def send_choice_picker( + self, + chat_id: str, + title: str, + choices: list, + session_key: str, + on_choice_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a Matrix reaction-based choice picker (/reasoning, /fast). + + Generic single-level companion to ``send_model_picker``. Each choice + dict: ``{"value": str, "label": str, "is_current": bool}``. + """ + if not self._client: + return SendResult(success=False, error="Not connected") + + emoji_choices: dict[str, str] = {} + lines = [title, ""] + for i, choice in enumerate(choices): + if i >= len(_MATRIX_CHOICE_PICKER_REACTIONS): + break + emoji = _MATRIX_CHOICE_PICKER_REACTIONS[i] + value = str(choice.get("value") or "") + label = str(choice.get("label") or value) + if choice.get("is_current"): + label = f"{label} ← current" + emoji_choices[emoji] = value + lines.append(f"{emoji} {label}") + + if not emoji_choices: + return SendResult(success=False, error="No choices") + + lines.append("") + lines.append("React to choose.") + result = await self.send(chat_id, "\n".join(lines), metadata=metadata) + if not result.success or not result.message_id: + return result + + prompt = _MatrixChoicePickerPrompt( + chat_id=chat_id, + message_id=result.message_id, + session_key=session_key, + choices=emoji_choices, + on_choice_selected=on_choice_selected, + requester_user_id=str((metadata or {}).get("requester_user_id") or "") or None, + expires_at=time.monotonic() + max(self._approval_timeout_seconds, 0), + ) + self._choice_picker_prompts_by_event[result.message_id] = prompt + + for emoji in emoji_choices: + try: + reaction_event_id = await self._send_reaction(chat_id, result.message_id, emoji) + if reaction_event_id: + prompt.bot_reaction_events[emoji] = str(reaction_event_id) + except Exception as exc: + logger.debug("Matrix: failed to add choice picker reaction %s: %s", emoji, exc) + + return result + def format_message(self, content: str) -> str: """Pass-through — Matrix supports standard Markdown natively.""" # Strip image markdown; media is uploaded separately. @@ -3328,6 +3412,40 @@ class MatrixAdapter(BasePlatformAdapter): ) return + choice_prompt = self._choice_picker_prompts_by_event.get(reacts_to) + if choice_prompt and not choice_prompt.resolved: + if room_id != choice_prompt.chat_id: + return + if self._matrix_prompt_expired(choice_prompt): + self._choice_picker_prompts_by_event.pop(reacts_to, None) + return + if not await self._validate_matrix_prompt_reactor( + room_id, reacts_to, sender, choice_prompt, "choice picker" + ): + return + value = choice_prompt.choices.get(key) + if value is None: + await self._send_invalid_reaction_feedback( + room_id, + reacts_to, + "That reaction is not one of the available choices.", + ) + return + choice_prompt.resolved = True + self._choice_picker_prompts_by_event.pop(reacts_to, None) + try: + confirmation = await choice_prompt.on_choice_selected(room_id, value) + if confirmation: + await self.send(room_id, confirmation, reply_to=reacts_to) + except Exception as exc: + logger.error("Failed to apply choice from Matrix reaction: %s", exc) + await self.send( + room_id, + f"Failed to apply selection: {exc}", + reply_to=reacts_to, + ) + return + def _matrix_prompt_expired(self, prompt: Any) -> bool: expires_at = getattr(prompt, "expires_at", None) return expires_at is not None and time.monotonic() > float(expires_at) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 1af072adea76..7f451f8c4c0f 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -778,6 +778,7 @@ class TelegramAdapter(BasePlatformAdapter): ) # Interactive model picker state per chat self._model_picker_state: Dict[str, dict] = {} + self._choice_picker_state: Dict[str, dict] = {} # Approval button state: message_id → session_key self._approval_state: Dict[int, str] = {} # Slash-confirm button state: confirm_id → session_key (for /reload-mcp @@ -5213,6 +5214,128 @@ class TelegramAdapter(BasePlatformAdapter): return SendResult(success=False, error=_redact_telegram_error_text(e)) _PROVIDER_PAGE_SIZE = 10 + + async def send_choice_picker( + self, + chat_id: str, + title: str, + choices: list, + session_key: str, + on_choice_selected, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a flat inline-keyboard choice picker (one tap → one value). + + Generic single-level companion to ``send_model_picker`` used by + `/reasoning`, `/fast`, and any future finite-choice command. Each + choice dict: ``{"value": str, "label": str, "is_current": bool}``. + """ + if not self._bot: + return SendResult(success=False, error="Not connected") + + try: + buttons = [] + for i, choice in enumerate(choices): + label = str(choice.get("label") or choice.get("value") or "") + if choice.get("is_current"): + label = f"✓ {label}" + buttons.append( + InlineKeyboardButton(label, callback_data=f"cp:{i}") + ) + if not buttons: + return SendResult(success=False, error="No choices") + # Two buttons per row keeps labels readable on mobile. + keyboard = InlineKeyboardMarkup( + [buttons[i:i + 2] for i in range(0, len(buttons), 2)] + ) + + thread_id = metadata.get("thread_id") if metadata else None + reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode) + msg = await self._send_message_with_thread_fallback( + chat_id=normalize_telegram_chat_id(chat_id), + text=self.format_message(title), + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=keyboard, + reply_to_message_id=reply_to_id, + **self._thread_kwargs_for_send( + chat_id, + thread_id, + metadata, + reply_to_message_id=reply_to_id, + reply_to_mode=self._reply_to_mode + ), + **self._link_preview_kwargs(), + ) + + self._choice_picker_state[str(chat_id)] = { + "msg_id": msg.message_id, + "choices": choices, + "session_key": session_key, + "on_choice_selected": on_choice_selected, + } + return SendResult(success=True, message_id=str(msg.message_id)) + except Exception as e: + logger.warning("[%s] send_choice_picker failed: %s", self.name, _redact_telegram_error_text(e)) + return SendResult(success=False, error=_redact_telegram_error_text(e)) + + async def _handle_choice_picker_callback( + self, query, data: str, chat_id: str + ) -> None: + """Handle choice picker button taps (cp:).""" + state = self._choice_picker_state.get(chat_id) + if not state: + await query.answer(text="Picker expired — run the command again.") + return + + # Same authorization gate as approval buttons: unauthorized users in a + # shared group must not flip session/config state via someone else's + # picker message. + query_message = getattr(query, "message", None) + query_chat = getattr(query_message, "chat", None) + if not self._is_callback_user_authorized( + str(getattr(query.from_user, "id", "")), + chat_id=getattr(query_message, "chat_id", None), + chat_type=str(getattr(query_chat, "type", None)) if getattr(query_chat, "type", None) is not None else None, + thread_id=str(getattr(query_message, "message_thread_id", None)) if getattr(query_message, "message_thread_id", None) is not None else None, + user_name=getattr(query.from_user, "first_name", None), + ): + await query.answer(text="⛔ You are not authorized to change this setting.") + return + + try: + idx = int(data[3:]) + choice = state["choices"][idx] + except (ValueError, IndexError): + await query.answer(text="Invalid selection.") + return + + callback = state.get("on_choice_selected") + if not callback: + await query.answer(text="Picker expired.") + return + + try: + result_text = await callback(chat_id, str(choice.get("value") or "")) + except Exception as exc: + logger.error("Choice picker selection failed: %s", exc) + result_text = f"Error applying selection: {exc}" + + try: + await query.edit_message_text( + text=self.format_message(result_text), + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=None, + ) + except Exception: + try: + await query.edit_message_text( + text=result_text, parse_mode=None, reply_markup=None, + ) + except Exception: + pass + await query.answer() + self._choice_picker_state.pop(chat_id, None) + _MODEL_PAGE_SIZE = 8 def _build_provider_keyboard(self, providers: list, page: int = 0) -> tuple: @@ -5709,6 +5832,13 @@ class TelegramAdapter(BasePlatformAdapter): await self._handle_model_picker_callback(query, data, chat_id) return + # --- Generic choice picker callbacks (/reasoning, /fast) --- + if data.startswith("cp:"): + chat_id = str(query.message.chat_id) if query.message else None + if chat_id: + await self._handle_choice_picker_callback(query, data, chat_id) + return + # --- Gmail-triage callbacks (gt:verb:arg) --- if data.startswith("gt:"): await self._handle_gmail_triage_callback( diff --git a/tests/gateway/test_choice_picker.py b/tests/gateway/test_choice_picker.py new file mode 100644 index 000000000000..eb4878273366 --- /dev/null +++ b/tests/gateway/test_choice_picker.py @@ -0,0 +1,224 @@ +"""Tests for the gateway interactive choice picker (/reasoning, /fast). + +The picker mirrors the /model picker architecture: the gateway gates on the +adapter *type* exposing ``send_choice_picker``, sends a flat choice list, and +falls back to the text status card when the platform has no picker or the +send fails. Selection flows through the same application path as the typed +command, so picker and typed arguments can never diverge. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent, SendResult +from gateway.session import SessionSource + + +def _make_event(text="/reasoning", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"): + source = SessionSource( + platform=platform, + user_id=user_id, + chat_id=chat_id, + user_name="testuser", + ) + return MessageEvent(text=text, source=source) + + +class _PickerAdapter: + """Adapter whose *type* exposes ``send_choice_picker`` (the gate the + handler checks via ``getattr(type(adapter), 'send_choice_picker', None)``).""" + + def __init__(self, success=True): + self.calls = [] + self._success = success + + async def send_choice_picker(self, **kwargs): + self.calls.append(kwargs) + return SendResult(success=self._success, message_id="m1") + + +class _NoPickerAdapter: + """Adapter with no choice-picker capability.""" + + +def _make_runner(adapter=None): + runner = object.__new__(gateway_run.GatewayRunner) + runner.adapters = {} + runner._ephemeral_system_prompt = "" + runner._prefill_messages = [] + runner._reasoning_config = None + runner._session_reasoning_overrides = {} + runner._show_reasoning = False + runner._provider_routing = {} + runner._fallback_model = None + runner._running_agents = {} + runner.hooks = MagicMock() + runner.hooks.emit = AsyncMock() + runner.hooks.loaded_hooks = [] + runner._session_db = None + runner._get_or_create_gateway_honcho = lambda session_key: (None, None) + runner._adapter_for_source = lambda source: adapter + runner._thread_metadata_for_source = lambda source, anchor=None: {} + runner._reply_anchor_for_event = lambda event: None + return runner + + +class TestReasoningChoicePicker: + @pytest.mark.asyncio + async def test_bare_reasoning_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + + result = await runner._handle_reasoning_command(_make_event("/reasoning")) + + assert result is None # picker sent — adapter owns the response + assert len(adapter.calls) == 1 + call = adapter.calls[0] + values = [c["value"] for c in call["choices"]] + # Full canonical ladder + none + subcommands, in order + from hermes_constants import VALID_REASONING_EFFORTS + assert values[0] == "none" + assert values[1:1 + len(VALID_REASONING_EFFORTS)] == list(VALID_REASONING_EFFORTS) + assert values[-3:] == ["reset", "show", "hide"] + + @pytest.mark.asyncio + async def test_bare_reasoning_falls_back_to_text_without_picker(self, tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + runner = _make_runner(_NoPickerAdapter()) + + result = await runner._handle_reasoning_command(_make_event("/reasoning")) + + assert isinstance(result, str) + assert "/reasoning" in result # text status card + + @pytest.mark.asyncio + async def test_bare_reasoning_falls_back_to_text_when_picker_send_fails(self, tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + adapter = _PickerAdapter(success=False) + runner = _make_runner(adapter) + + result = await runner._handle_reasoning_command(_make_event("/reasoning")) + + assert isinstance(result, str) + assert len(adapter.calls) == 1 # attempted, then fell back + + @pytest.mark.asyncio + async def test_typed_argument_never_sends_picker(self, tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + + result = await runner._handle_reasoning_command(_make_event("/reasoning high")) + + assert isinstance(result, str) + assert adapter.calls == [] + + @pytest.mark.asyncio + async def test_picker_selection_applies_same_as_typed(self, tmp_path, monkeypatch): + """The picker's on_choice_selected must produce the identical state + change as typing the argument (single application path).""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + event = _make_event("/reasoning") + session_key = runner._session_key_for_source(event.source) + + await runner._handle_reasoning_command(event) + on_choice = adapter.calls[0]["on_choice_selected"] + + reply = await on_choice(event.source.chat_id, "ultra") + + assert "ultra" in reply + override = runner._session_reasoning_overrides.get(session_key) + assert override == {"enabled": True, "effort": "ultra"} + + @pytest.mark.asyncio + async def test_picker_selection_of_current_level_marks_is_current(self, tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"agent": {"reasoning_effort": "xhigh"}}), encoding="utf-8" + ) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + + await runner._handle_reasoning_command(_make_event("/reasoning")) + + current = [c["value"] for c in adapter.calls[0]["choices"] if c.get("is_current")] + assert current == ["xhigh"] + + @pytest.mark.asyncio + async def test_picker_show_choice_toggles_display(self, tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + event = _make_event("/reasoning") + + await runner._handle_reasoning_command(event) + on_choice = adapter.calls[0]["on_choice_selected"] + await on_choice(event.source.chat_id, "show") + + assert runner._show_reasoning is True + saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert saved["display"]["platforms"]["telegram"]["show_reasoning"] is True + + +class TestFastChoicePicker: + def _patch_fast_support(self, monkeypatch, tmp_path): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda cfg: "gpt-5.6") + import hermes_cli.models as models_mod + monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda m: True) + + @pytest.mark.asyncio + async def test_bare_fast_sends_picker_when_adapter_supports_it(self, tmp_path, monkeypatch): + self._patch_fast_support(monkeypatch, tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + + result = await runner._handle_fast_command(_make_event("/fast")) + + assert result is None + values = [c["value"] for c in adapter.calls[0]["choices"]] + assert values == ["fast", "normal"] + + @pytest.mark.asyncio + async def test_fast_picker_selection_persists_service_tier(self, tmp_path, monkeypatch): + self._patch_fast_support(monkeypatch, tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + event = _make_event("/fast") + + await runner._handle_fast_command(event) + on_choice = adapter.calls[0]["on_choice_selected"] + await on_choice(event.source.chat_id, "fast") + + assert runner._service_tier == "priority" + saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert saved["agent"]["service_tier"] == "fast" + + @pytest.mark.asyncio + async def test_bare_fast_falls_back_to_text_without_picker(self, tmp_path, monkeypatch): + self._patch_fast_support(monkeypatch, tmp_path) + runner = _make_runner(_NoPickerAdapter()) + + result = await runner._handle_fast_command(_make_event("/fast")) + + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_typed_fast_argument_never_sends_picker(self, tmp_path, monkeypatch): + self._patch_fast_support(monkeypatch, tmp_path) + adapter = _PickerAdapter() + runner = _make_runner(adapter) + + result = await runner._handle_fast_command(_make_event("/fast normal")) + + assert isinstance(result, str) + assert adapter.calls == []