feat(gateway): inline choice pickers for /reasoning and /fast (Telegram, Discord, Matrix) (#65799)

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:<idx> 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.
This commit is contained in:
Teknium 2026-07-16 10:38:31 -07:00 committed by GitHub
parent 659d1123c4
commit bd37ff9138
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 970 additions and 104 deletions

View file

@ -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:<id>:<idx>`, `appr:<id>:<choice>`, `sc:<choice>:<id>`) is shared across adapters — match it so the gateway-side resolvers work without modification.

View file

@ -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 <arg>` 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."""

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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_(次のメッセージから有効)_"

View file

@ -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 <level> --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_(다음 메시지부터 적용됩니다)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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_(вступит в силу со следующего сообщения)_"

View file

@ -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 <level> --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)_"

View file

@ -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 <level> --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_(набуде чинності з наступного повідомлення)_"

View file

@ -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 <level> --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_下一則訊息生效_"

View file

@ -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 <level> --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_下一条消息生效_"

View file

@ -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.

View file

@ -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)

View file

@ -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:<index>)."""
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(

View file

@ -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 == []