diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatDb.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatDb.kt index c21ecc2..0ebeb51 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChatDb.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChatDb.kt @@ -36,7 +36,7 @@ class ChatDb( * tool cards interleaved at their anchored position). */ fun loadLanes(): Map> = synchronized(lock) { - val messages = linkedMapOf>() + val messages = linkedMapOf>() for (row in db.cacheQueries.allMessages().executeAsList()) { val item = decodeMessage(row.payload) ?: continue messages.getOrPut(row.lane) { mutableListOf() }.add(item) @@ -96,6 +96,12 @@ class ChatDb( is ToolItem -> { db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item)) } + + // Picker cards ride in the message table (JSON + // payload; decodeMessage picks the type back out). + is PickerItem -> { + db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item)) + } } } } @@ -159,11 +165,17 @@ class ChatDb( } } - private fun decodeMessage(payload: String): MessageItem? = + private fun decodeMessage(payload: String): ChatItem? = try { json.decodeFromString(payload).sanitizeForRestore() } catch (_: Exception) { - null + // Not a message payload — a picker card (MessageItem requires + // "role", PickerItem requires "title": the two never cross-decode). + try { + json.decodeFromString(payload) + } catch (_: Exception) { + null + } } private fun decodeTool(payload: String): ToolItem? = diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt index 7b0bd62..e499bd2 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt @@ -8,6 +8,8 @@ import iris.protocol.MessagePayload import iris.protocol.MessageStartPayload import iris.protocol.MessageStopPayload import iris.protocol.MessageUpdatePayload +import iris.protocol.PickerChoice +import iris.protocol.PickerChoicePayload import iris.protocol.ROLE_ASSISTANT import iris.protocol.ROLE_USER import iris.protocol.RuntimeMeta @@ -17,6 +19,7 @@ import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_UPDATE +import iris.protocol.TYPE_PICKER_CHOICE import iris.protocol.TYPE_TOOL_END import iris.protocol.TYPE_TOOL_PROGRESS import iris.protocol.TYPE_TOOL_START @@ -114,6 +117,20 @@ data class ToolItem( val anchorId: String? = null, ) : ChatItem +/** An interactive choice picker card (one tap → one value), sent by + * finite-choice slash commands (/reasoning, /fast, …) via `picker.choice`. + * [selected] is the value the user tapped (null = still pending); the + * server's reply arrives as a normal message afterwards. + * [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */ +@Serializable +data class PickerItem( + override val id: String, // picker_id (unique; the picker.select key) + val title: String, + val choices: List = emptyList(), + val ts: Long = 0, + val selected: String? = null, +) : ChatItem + class ChatStore { /** lane key -> chronological items. */ private val _lanes = MutableStateFlow>>(emptyMap()) @@ -219,6 +236,7 @@ class ChatStore { TYPE_TOOL_END -> onToolEnd(lane, frame) TYPE_COMMENTARY -> onCommentary(lane, frame) TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame) + TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame) else -> Unit } } @@ -600,6 +618,60 @@ class ChatStore { if (changed) _lanes.value = map } + // ── picker.choice (interactive slash-command menu) ────────────────────── + + /** + * Append a choice-picker card to [lane]. Idempotent by picker id: the + * frame is outboxed, so a sync replay of an already-rendered picker is a + * no-op (a locally resolved card is never re-opened by a replay). + */ + private fun onPickerChoice( + lane: String, + frame: Frame, + ) { + val p = frame.payloadAs() ?: return + updateLane(lane) { list -> + if (list.any { it.id == p.pickerId }) { + list + } else { + list + + PickerItem( + id = p.pickerId, + title = p.title, + choices = p.choices, + ts = nowMillis(), + ) + } + } + } + + /** Mark the picker [pickerId] as answered with [value] (all lanes; the + * picker id is unique). Optimistic: the server's reply message follows + * as a normal message; an expired picker (gateway restart) simply never + * replies. */ + fun resolvePicker( + pickerId: String, + value: String, + ) { + val map = _lanes.value.toMutableMap() + var changed = false + for ((lane, list) in map) { + val updated = + list.map { item -> + if (item is PickerItem && item.id == pickerId && item.selected == null) { + item.copy(selected = value) + } else { + item + } + } + if (updated != list) { + map[lane] = updated + changed = true + } + } + if (changed) _lanes.value = map + } + /** M5: mark the user message [messageId] as read (read.receipt). */ fun markRead(messageId: String) { val map = _lanes.value.toMutableMap() @@ -684,6 +756,7 @@ class ChatStore { when (item) { is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item is MessageItem -> if (item.streaming) item.copy(streaming = false) else item + is PickerItem -> item } } if (updated != list) { @@ -788,6 +861,8 @@ class ChatStore { // A resolved ts of 0 means the anchor itself is ts-less // (lane start) — sort with it (end) instead of to the top. is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE + + is PickerItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE } } } diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index fc8d45b..950903d 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -76,6 +76,10 @@ const val TYPE_SEARCH_RESULTS = "search.results" // Slash-command catalog (the composer's "/" drawer) const val TYPE_COMMANDS_CATALOG = "commands.catalog" + +// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast) +const val TYPE_PICKER_CHOICE = "picker.choice" +const val TYPE_PICKER_SELECT = "picker.select" const val TYPE_SYNC = "sync" const val TYPE_SYNC_DONE = "sync.done" const val TYPE_HISTORY = "history" @@ -413,6 +417,27 @@ data class CommandsCatalogPayload( val commands: List = emptyList(), ) +// ── picker.choice / picker.select (interactive slash-command menus) ──────── + +/** One option in a choice picker. [isCurrent] marks the active value (the + * UI shows a ✓ on it, like Telegram's inline keyboards). */ +@Serializable +data class PickerChoice( + val value: String, + val label: String = value, + @SerialName("is_current") val isCurrent: Boolean = false, +) + +/** An interactive choice picker (one tap → one value), sent by finite-choice + * slash commands (/reasoning, /fast, …). The app renders it as a card with + * buttons and answers via [pickerSelectFrame]. */ +@Serializable +data class PickerChoicePayload( + @SerialName("picker_id") val pickerId: String, + val title: String, + val choices: List = emptyList(), +) + // ── M3: sync (reconnect catch-up) ─────────────────────────────────────── @Serializable @@ -640,6 +665,24 @@ fun searchFrame( * Answered by a `commands.catalog` frame carrying the same id. */ fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG) +/** Answer an interactive picker (picker.choice) with the chosen value. + * The server runs the command's selection callback and delivers its reply + * as a normal message in the picker's chat. */ +fun pickerSelectFrame( + id: Int, + pickerId: String, + value: String, +): Frame = + Frame( + id = id, + type = TYPE_PICKER_SELECT, + payload = + buildJsonObject { + put("picker_id", pickerId) + put("value", value) + }, + ) + fun syncFrame( id: Int, cursor: Long, diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index 5d74c48..67b30ff 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -51,6 +51,7 @@ import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_UPDATE import iris.protocol.TYPE_NOTIFICATION +import iris.protocol.TYPE_PICKER_CHOICE import iris.protocol.TYPE_READ_RECEIPT import iris.protocol.TYPE_SEARCH_RESULTS import iris.protocol.TYPE_STATUS @@ -71,6 +72,7 @@ import iris.protocol.channelSetDefaultFrame import iris.protocol.commandsCatalogFrame import iris.protocol.historyFrame import iris.protocol.messageDeleteFrame +import iris.protocol.pickerSelectFrame import iris.protocol.searchFrame import iris.protocol.syncFrame import iris.ui.theme.Backdrop @@ -570,6 +572,12 @@ class IrisController( _searching.value = false } + TYPE_PICKER_CHOICE -> { + // Interactive slash-command menu (/reasoning, /fast, …): + // rendered as a tappable card in the lane. + chat.onFrame(frame) + } + TYPE_COMMANDS_CATALOG -> { frame.payloadAs()?.let { _slashCommands.value = it.commands } } @@ -803,6 +811,18 @@ class IrisController( // ── M3: channel directory ops (server is authoritative) ─────────────── + /** Answer an interactive choice picker (picker.choice card). Marks the + * card resolved locally (optimistic) and sends picker.select; the + * server's reply arrives as a normal message. An expired picker + * (gateway restart) simply never replies. */ + fun selectPicker( + pickerId: String, + value: String, + ) { + chat.resolvePicker(pickerId, value) + client.sendFrame(pickerSelectFrame(0, pickerId, value)) + } + fun createChannel(name: String) { val trimmed = name.trim() if (trimmed.isEmpty()) return diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index bd069a2..7ad4d6e 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -100,6 +100,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -109,6 +110,7 @@ import iris.data.ChatItem import iris.data.MediaItem import iris.data.MessageItem import iris.data.MsgStatus +import iris.data.PickerItem import iris.data.ToolItem import iris.net.GatewayClient import iris.platform.ImageFilePicker @@ -610,6 +612,12 @@ fun ChatScreen(controller: IrisController) { is ToolItem -> { if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail) } + + is PickerItem -> { + PickerCard(item) { value -> + controller.selectPicker(item.id, value) + } + } } } } @@ -2919,6 +2927,80 @@ internal val ToolDetail.label: String ToolDetail.NOTHING -> "nothing" } +/** + * Interactive choice-picker card (picker.choice) — the app-side equivalent of + * Telegram's inline keyboard for finite-choice slash commands (/reasoning, + * /fast, …). Two buttons per row, ✓ on the current value. After a tap the + * card locks to the picked value; the server's reply arrives as a normal + * message afterwards. + */ +@Composable +private fun PickerCard( + picker: PickerItem, + onPick: (String) -> Unit, +) { + val selected = picker.selected + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(IrisColors.panel) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + MarkdownText( + text = picker.title, + color = IrisColors.textBright, + fontSize = 13.sp, + ) + picker.choices.chunked(2).forEach { rowChoices -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + ) { + rowChoices.forEach { choice -> + val isPicked = selected == choice.value + val isCurrent = choice.isCurrent && selected == null + Text( + text = (if (isPicked || isCurrent) "✓ " else "") + choice.label, + fontSize = 13.sp, + color = + when { + isPicked -> IrisColors.statusGreen + selected != null -> IrisColors.textDim + else -> IrisColors.textBright + }, + textAlign = TextAlign.Center, + modifier = + Modifier + .weight(1f) + .padding(horizontal = 3.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + when { + isPicked -> IrisColors.chipSelected + else -> IrisColors.chip + }, + ).then( + if (selected == null) { + Modifier.clickable { onPick(choice.value) } + } else { + Modifier + }, + ).padding(vertical = 8.dp), + ) + } + // Pad a short last row so the button keeps its half width. + if (rowChoices.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + // ── Slash-command drawer ────────────────────────────────────────────────── /** Max rows in the slash drawer (the rest is reachable by typing more). */ diff --git a/app/shared/src/commonTest/kotlin/iris/data/ChatStorePickerTest.kt b/app/shared/src/commonTest/kotlin/iris/data/ChatStorePickerTest.kt new file mode 100644 index 0000000..1d8b602 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/data/ChatStorePickerTest.kt @@ -0,0 +1,108 @@ +package iris.data + +import iris.protocol.Frame +import iris.protocol.IrisJson +import iris.protocol.PickerChoice +import iris.protocol.PickerChoicePayload +import iris.protocol.TYPE_PICKER_CHOICE +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ChatStorePickerTest { + private fun pickerFrame( + pickerId: String, + title: String = "Reasoning", + chatId: String = "chan_1", + ): Frame = + Frame( + type = TYPE_PICKER_CHOICE, + chatId = chatId, + payload = + IrisJson.instance.encodeToJsonElement( + PickerChoicePayload.serializer(), + PickerChoicePayload( + pickerId = pickerId, + title = title, + choices = + listOf( + PickerChoice(value = "low", label = "Low"), + PickerChoice(value = "high", label = "High", isCurrent = true), + ), + ), + ), + ) + + @Test + fun onPickerChoiceAddsItem() { + val store = ChatStore() + store.onFrame(pickerFrame("pk_1")) + val items = store.lanes.value["chan_1"]!! + assertEquals(1, items.size) + val picker = assertIs(items[0]) + assertEquals("pk_1", picker.id) + assertEquals("Reasoning", picker.title) + assertEquals(2, picker.choices.size) + assertNull(picker.selected) + } + + @Test + fun onPickerChoiceIsIdempotent() { + val store = ChatStore() + store.onFrame(pickerFrame("pk_1")) + store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery) + val items = store.lanes.value["chan_1"]!! + assertEquals(1, items.size) + assertEquals("pk_1", items[0].id) + } + + @Test + fun resolvePickerMarksSelected() { + val store = ChatStore() + store.onFrame(pickerFrame("pk_1")) + store.resolvePicker("pk_1", "low") + val picker = assertIs(store.lanes.value["chan_1"]!![0]) + assertEquals("low", picker.selected) + } + + @Test + fun resolvePickerIsOneShot() { + val store = ChatStore() + store.onFrame(pickerFrame("pk_1")) + store.resolvePicker("pk_1", "low") + store.resolvePicker("pk_1", "high") // second tap is ignored + val picker = assertIs(store.lanes.value["chan_1"]!![0]) + assertEquals("low", picker.selected) + } + + @Test + fun resolvePickerUnknownIdIsNoop() { + val store = ChatStore() + store.onFrame(pickerFrame("pk_1")) + store.resolvePicker("pk_missing", "low") // stale select after restart + val picker = assertIs(store.lanes.value["chan_1"]!![0]) + assertNull(picker.selected) + } + + @Test + fun pickerItemSerializationRoundTrip() { + val item = + PickerItem( + id = "pk_1", + title = "Reasoning", + choices = + listOf( + PickerChoice(value = "low", label = "Low"), + PickerChoice(value = "high", label = "High", isCurrent = true), + ), + ts = 1234L, + selected = "high", + ) + val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item) + val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json) + assertEquals(item, back) + assertTrue(back.choices[1].isCurrent) + } +} diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index 40c4838..67ade76 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -161,6 +161,16 @@ In-app banner (foreground) and/or push mirror (background). Interactive prompts. App renders a native picker; answers via `picker.select`. +`picker.choice` is implemented (the generic finite-choice menu used by +`/reasoning`, `/fast`, and any future finite-choice slash command — hermes +calls the adapter's `send_choice_picker` when the platform supports it). +The server runs the command's selection callback on `picker.select` and +delivers its reply as a normal `message` in the picker's chat. The frame is +outboxed (a reconnecting device re-renders a still-pending picker); pending +state is in-memory only, so a gateway restart expires it (a stale +`picker.select` is a no-op). With no live device the adapter reports failure +and hermes falls back to the text status card. + ```json {"type":"picker.model","chat_id":"…","payload":{ "picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local", diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 41e156f..967b669 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -66,7 +66,8 @@ "history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "runtime": {"$ref":"#/definitions/runtime"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }, - "commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } } + "commands.catalog": { "description": "Response to a commands.catalog request: the gateway's slash-command catalog for the app's '/' drawer. Derived from hermes' COMMAND_REGISTRY (gateway-available subset) plus plugin-registered commands. The app fuzzy-matches the typed prefix client-side.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Canonical command with leading slash, e.g. \"/new\"." }, "description": { "type": "string" }, "args_hint": { "type": "string", "description": "Argument placeholder, e.g. \"[name]\"; empty when none." }, "category": { "type": "string", "description": "Registry category (Session, Configuration, Tools & Skills, Info, Exit, Plugin)." }, "aliases": { "type": "array", "items": { "type": "string" }, "description": "Alternative names with leading slash, e.g. [\"/reset\"] for /new." } } } } } }, + "picker.choice": { "description": "Interactive choice picker (one tap -> one value) for finite-choice slash commands (/reasoning, /fast, ...). The app renders the title + choice buttons and answers with picker.select carrying the same picker_id. Outboxed, so a reconnecting device re-renders a still-pending picker; pending state is in-memory only (a gateway restart expires it).", "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "value": { "type": "string" }, "label": { "type": "string" }, "is_current": { "type": "boolean" } } } } } } }, "app_to_server": { "hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, @@ -88,6 +89,7 @@ "history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } }, "message.delete": { "description": "Completely delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), then broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } }, "fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, + "picker.select": { "description": "Answer an interactive picker (picker.choice). The server runs the command's selection callback and delivers its reply as a normal message in the picker's chat. Unknown/expired picker ids are a no-op.", "payload": { "picker_id": { "type": "string" }, "value": { "type": "string" } } }, "ping": { "payload": { "ts": { "type": "integer" } } } } }, @@ -99,11 +101,9 @@ }, "x-planned-frames": [ { "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." }, - { "name": "picker.choice", "direction": "server_to_app", "note": "Generic choice picker prompt. Planned, not implemented." }, { "name": "picker.clarify", "direction": "server_to_app", "note": "Clarify picker prompt. Planned, not implemented (clarifies arrive as notification + message)." }, { "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." }, { "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." }, - { "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." }, { "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented (the app fuzzy-matches the commands.catalog list client-side)." }, { "name": "commands.complete", "direction": "app_to_server", "note": "Slash-command autocomplete request. Planned, not implemented." }, { "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." }, diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 28fcc6f..c80d09f 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -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]]: diff --git a/gateway-plugin/dispatch.py b/gateway-plugin/dispatch.py index 003c999..0b5cac5 100644 --- a/gateway-plugin/dispatch.py +++ b/gateway-plugin/dispatch.py @@ -57,6 +57,8 @@ async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> await adapter.on_message_delete(frame, device_id) elif frame.type == protocol.TYPE_FCM_REGISTER: await adapter.on_fcm_register(frame, device_id) + elif frame.type == protocol.TYPE_PICKER_SELECT: + await adapter.on_picker_select(frame, device_id) # Unknown types are ignored (forward-compat). diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index e82fe47..c985354 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -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) # --------------------------------------------------------------------------- diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index 97f3e1e..e78f487 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -97,6 +97,13 @@ def plugin(): def adapter(plugin, monkeypatch): """A live IrisAdapter with an isolated HERMES_HOME (conftest).""" monkeypatch.setenv("IRIS_TOKEN", TOKEN) + # Clear any IRIS transport overrides leaked into the process env by earlier + # tests (e.g. test_interactive_setup_prints_qr runs the real + # interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_WS_HOST). + # Without this, a later adapter would bind the leaked port (8791) instead of + # the ephemeral 0 below, colliding with a live gateway on that port. + monkeypatch.delenv("IRIS_HTTP_PORT", raising=False) + monkeypatch.delenv("IRIS_WS_HOST", raising=False) from gateway.platform_registry import PlatformEntry, platform_registry # Platform("iris") resolves only once the platform is registered @@ -2397,4 +2404,88 @@ async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client): for c in commands: assert c["name"].startswith("/") assert "description" in c and "args_hint" in c and "category" in c - assert isinstance(c["aliases"], list) \ No newline at end of file + assert isinstance(c["aliases"], list) + +# ── Interactive pickers (picker.choice / picker.select) ─────────────────── + + +@pytest.mark.asyncio +async def test_choice_picker_roundtrip(plugin, adapter, ws_client): + """send_choice_picker emits a picker.choice frame; a picker.select from + the app runs the selection callback and delivers its reply as a normal + final message. The picker is consumed (a second select is a no-op).""" + ws, _ = ws_client + picked: list = [] + + async def on_choice(chat_id, value): + picked.append((chat_id, value)) + return f"picked {value}" + + res = await adapter.send_choice_picker( + "default", + "Pick one", + [ + {"value": "a", "label": "A", "is_current": False}, + {"value": "b", "label": "B", "is_current": True}, + ], + "sess", + on_choice, + ) + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice") + pc = frames[-1] + assert pc["chat_id"] == "default" + pid = pc["payload"]["picker_id"] + assert pid == res.message_id + assert pc["payload"]["title"] == "Pick one" + assert pc["payload"]["choices"][1]["is_current"] is True + + # The app taps a button -> picker.select. + await ws.send( + json.dumps( + {"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "b"}} + ) + ) + assert picked == [("default", "b")] + # The callback's reply arrives as a normal final assistant message. + frames2 = await recv_until( + ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "picked b" + ) + assert frames2[-1]["payload"]["role"] == "assistant" + # The picker is consumed: a second select for the same id is a no-op. + await ws.send( + json.dumps( + {"type": "picker.select", "id": 2, "payload": {"picker_id": pid, "value": "a"}} + ) + ) + await asyncio.sleep(0.2) + assert picked == [("default", "b")] + + +@pytest.mark.asyncio +async def test_choice_picker_requires_live_device(adapter): + """With no live device the picker could never be answered, so the adapter + reports failure and hermes falls back to the text status card.""" + res = await adapter.send_choice_picker( + "default", + "t", + [{"value": "a", "label": "A"}], + "s", + lambda c, v: "x", + ) + assert not res.success + assert not adapter._pending_pickers + + +@pytest.mark.asyncio +async def test_picker_select_unknown_id_is_noop(plugin, adapter, ws_client): + """A stale picker.select (gateway restart expired the pending state) is + ignored without error.""" + ws, _ = ws_client + await ws.send( + json.dumps( + {"type": "picker.select", "id": 1, "payload": {"picker_id": "pc_nope", "value": "a"}} + ) + ) + await asyncio.sleep(0.2) + assert not adapter._pending_pickers