Add interactive choice-picker menus for finite-choice slash commands
Slash commands with a finite set of options (/reasoning, /fast, ...) now
render a tappable card with buttons (2 per row, ✓ on the current value)
instead of a plain text status card. The mechanism is generic: any command
that calls the adapter's send_choice_picker() gets a picker automatically.
Wire protocol (docs/04, frames.schema.json):
- picker.choice (server→app): {picker_id, title, choices[]}
- picker.select (app→server): {picker_id, value}
- pickers capability flag now True in server_caps
gateway-plugin:
- protocol.py: picker.choice/picker.select frame types + picker_choice()
- dispatch.py: route picker.select → adapter.on_picker_select
- adapter.py: send_choice_picker() (fails cleanly with no live device so
hermes falls back to text), on_picker_select(), in-memory pending pickers
(gateway restart expires them; stale select is a no-op), pickers=True
app (KMP):
- Protocol.kt: PickerChoice/PickerChoicePayload + pickerSelectFrame()
- ChatStore.kt: PickerItem + onPickerChoice (idempotent) + resolvePicker
(optimistic, one-shot)
- ChatDb.kt: persist PickerItem in the messages table (polymorphic decode)
- IrisController.kt: picker.choice routing + selectPicker() action
- ChatScreen.kt: PickerCard composable (locks after selection)
Tests:
- python: 3 picker tests (roundtrip, no-device fallback, stale-select noop)
- kotlin: ChatStorePickerTest (add/idempotent/resolve/one-shot/noop/serialize)
- fixture fix: clear leaked IRIS_HTTP_PORT/IRIS_WS_HOST env so the adapter
binds the ephemeral port (a prior test's interactive_setup() polluted the
process env, colliding with a live gateway on 8791)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
34a64d6e53
commit
82c5a20848
12 files changed
+565
-8
No files matched your search
@@ -562,6 +562,10 @@ def _mint_message_id() -> str:
|
||||
return f"m_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _mint_picker_id() -> str:
|
||||
return f"pc_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
|
||||
if not metadata:
|
||||
return None
|
||||
@@ -1212,6 +1216,10 @@ class IrisAdapter(BasePlatformAdapter):
|
||||
# last finalized assistant message id per chat (offer association).
|
||||
self._media = media_bridge.MediaStore(get_hermes_home())
|
||||
self._last_message_id: dict[str, str] = {}
|
||||
# Interactive choice pickers (slash-command menus, e.g. /reasoning,
|
||||
# /fast): picker_id -> pending state. In-memory only — a gateway
|
||||
# restart expires them (a stale picker.select is a no-op).
|
||||
self._pending_pickers: dict[str, dict] = {}
|
||||
# M5: push backend (FCM primary, ntfy fallback) + the throttle for
|
||||
# the outbox-prune banner.
|
||||
self._push: PushBackend = build_push_backend(
|
||||
@@ -2725,6 +2733,40 @@ class IrisAdapter(BasePlatformAdapter):
|
||||
return
|
||||
logger.info("iris: push tokens updated for %s", device_id)
|
||||
|
||||
# ── Interactive pickers (slash-command choice menus) ─────────────────
|
||||
|
||||
async def on_picker_select(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
"""Resolve a pending choice picker (``picker.select`` from the app).
|
||||
|
||||
Runs the command's selection callback and delivers its reply text as
|
||||
a normal final message in the picker's chat. Unknown/expired picker
|
||||
ids (gateway restart, double tap) are a no-op — the app already
|
||||
marked the card resolved locally.
|
||||
"""
|
||||
picker_id = frame.payload.get("picker_id")
|
||||
value = frame.payload.get("value")
|
||||
if not isinstance(picker_id, str) or not isinstance(value, str):
|
||||
return
|
||||
state = self._pending_pickers.pop(picker_id, None)
|
||||
if state is None:
|
||||
logger.info("iris: picker.select for unknown/expired picker %s", picker_id)
|
||||
return
|
||||
callback = state.get("on_choice_selected")
|
||||
if callback is None:
|
||||
return
|
||||
try:
|
||||
result_text = await callback(state["chat_id"], value)
|
||||
except Exception:
|
||||
logger.error("iris: picker selection failed for %s", picker_id, exc_info=True)
|
||||
return
|
||||
if not result_text:
|
||||
return
|
||||
await self.send(
|
||||
state["chat_id"],
|
||||
str(result_text),
|
||||
metadata={"notify": True, "thread_id": state.get("thread_id")},
|
||||
)
|
||||
|
||||
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||
|
||||
async def send_slash_confirm(
|
||||
@@ -2757,6 +2799,43 @@ class IrisAdapter(BasePlatformAdapter):
|
||||
chat_id, title, message, session_key, confirm_id, metadata=metadata
|
||||
)
|
||||
|
||||
async def send_choice_picker(
|
||||
self,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
choices: list,
|
||||
session_key: str,
|
||||
on_choice_selected,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> SendResult:
|
||||
"""Send an interactive choice picker (one tap → one value).
|
||||
|
||||
The generic companion to Telegram's inline-keyboard pickers, used by
|
||||
``/reasoning``, ``/fast``, and any future finite-choice slash command
|
||||
(hermes detects this method on the adapter type). Emits a
|
||||
``picker.choice`` frame; the app answers with ``picker.select``,
|
||||
which runs ``on_choice_selected(chat_id, value)`` and delivers the
|
||||
returned text as a normal message. Outboxed, so a reconnecting
|
||||
device re-renders a still-pending picker.
|
||||
|
||||
With no live device the picker could never be answered, so report
|
||||
failure and let hermes fall back to the text status card.
|
||||
"""
|
||||
if not self._http_server.has_devices():
|
||||
return SendResult(success=False, error="no live devices for picker")
|
||||
thread_id = _thread_id_from_metadata(metadata)
|
||||
picker_id = _mint_picker_id()
|
||||
self._pending_pickers[picker_id] = {
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id,
|
||||
"on_choice_selected": on_choice_selected,
|
||||
}
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.picker_choice(picker_id, title, choices, chat_id, thread_id=thread_id),
|
||||
)
|
||||
return SendResult(success=True, message_id=picker_id)
|
||||
|
||||
async def send_clarify(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -2873,7 +2952,7 @@ class IrisAdapter(BasePlatformAdapter):
|
||||
"push_ntfy_server": (
|
||||
self._push.server_url if isinstance(self._push, NtfyBackend) else ""
|
||||
),
|
||||
"pickers": False, # M2+
|
||||
"pickers": True, # picker.choice / picker.select (slash-command menus)
|
||||
}
|
||||
|
||||
def channel_list(self) -> list[dict[str, Any]]:
|
||||
|
||||
Reference in new issue
Block a user