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

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