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
@@ -71,6 +71,10 @@ TYPE_SEARCH_RESULTS = "search.results"
|
||||
# Slash-command catalog (app's "/" drawer)
|
||||
TYPE_COMMANDS_CATALOG = "commands.catalog"
|
||||
|
||||
# Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
|
||||
TYPE_PICKER_CHOICE = "picker.choice"
|
||||
TYPE_PICKER_SELECT = "picker.select"
|
||||
|
||||
# Reconnect catch-up (M3 outbox; extended by M5 push)
|
||||
TYPE_SYNC = "sync"
|
||||
TYPE_SYNC_DONE = "sync.done"
|
||||
@@ -606,6 +610,37 @@ def commands_catalog(commands: list[dict[str, Any]], *, id: int | None = None) -
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Picker frames (interactive slash-command choice menus)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def picker_choice(
|
||||
picker_id: str,
|
||||
title: str,
|
||||
choices: list[dict[str, Any]],
|
||||
chat_id: str,
|
||||
*,
|
||||
thread_id: str | None = None,
|
||||
) -> Frame:
|
||||
"""Event: an interactive choice picker (one tap → one value).
|
||||
|
||||
Used by slash commands with a finite option set (``/reasoning``,
|
||||
``/fast``, …) on platforms that support pickers. The app renders the
|
||||
title + choice buttons and answers with a ``picker.select`` frame
|
||||
carrying the same ``picker_id``. Outboxed, so a reconnecting device
|
||||
re-renders a still-pending picker.
|
||||
|
||||
Each choice: ``{"value": str, "label": str, "is_current": bool}``.
|
||||
"""
|
||||
return Frame(
|
||||
type=TYPE_PICKER_CHOICE,
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
payload={"picker_id": picker_id, "title": title, "choices": choices},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync frames (M3 outbox)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user