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
+564
-7
No files matched your search
@@ -36,7 +36,7 @@ class ChatDb(
|
|||||||
* tool cards interleaved at their anchored position). */
|
* tool cards interleaved at their anchored position). */
|
||||||
fun loadLanes(): Map<String, List<ChatItem>> =
|
fun loadLanes(): Map<String, List<ChatItem>> =
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val messages = linkedMapOf<String, MutableList<MessageItem>>()
|
val messages = linkedMapOf<String, MutableList<ChatItem>>()
|
||||||
for (row in db.cacheQueries.allMessages().executeAsList()) {
|
for (row in db.cacheQueries.allMessages().executeAsList()) {
|
||||||
val item = decodeMessage(row.payload) ?: continue
|
val item = decodeMessage(row.payload) ?: continue
|
||||||
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
|
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
|
||||||
@@ -96,6 +96,12 @@ class ChatDb(
|
|||||||
is ToolItem -> {
|
is ToolItem -> {
|
||||||
db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item))
|
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 {
|
try {
|
||||||
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
|
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
// Not a message payload — a picker card (MessageItem requires
|
||||||
|
// "role", PickerItem requires "title": the two never cross-decode).
|
||||||
|
try {
|
||||||
|
json.decodeFromString<PickerItem>(payload)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decodeTool(payload: String): ToolItem? =
|
private fun decodeTool(payload: String): ToolItem? =
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import iris.protocol.MessagePayload
|
|||||||
import iris.protocol.MessageStartPayload
|
import iris.protocol.MessageStartPayload
|
||||||
import iris.protocol.MessageStopPayload
|
import iris.protocol.MessageStopPayload
|
||||||
import iris.protocol.MessageUpdatePayload
|
import iris.protocol.MessageUpdatePayload
|
||||||
|
import iris.protocol.PickerChoice
|
||||||
|
import iris.protocol.PickerChoicePayload
|
||||||
import iris.protocol.ROLE_ASSISTANT
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.ROLE_USER
|
import iris.protocol.ROLE_USER
|
||||||
import iris.protocol.RuntimeMeta
|
import iris.protocol.RuntimeMeta
|
||||||
@@ -17,6 +19,7 @@ import iris.protocol.TYPE_MESSAGE
|
|||||||
import iris.protocol.TYPE_MESSAGE_START
|
import iris.protocol.TYPE_MESSAGE_START
|
||||||
import iris.protocol.TYPE_MESSAGE_STOP
|
import iris.protocol.TYPE_MESSAGE_STOP
|
||||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||||
|
import iris.protocol.TYPE_PICKER_CHOICE
|
||||||
import iris.protocol.TYPE_TOOL_END
|
import iris.protocol.TYPE_TOOL_END
|
||||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||||
import iris.protocol.TYPE_TOOL_START
|
import iris.protocol.TYPE_TOOL_START
|
||||||
@@ -114,6 +117,20 @@ data class ToolItem(
|
|||||||
val anchorId: String? = null,
|
val anchorId: String? = null,
|
||||||
) : ChatItem
|
) : 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<PickerChoice> = emptyList(),
|
||||||
|
val ts: Long = 0,
|
||||||
|
val selected: String? = null,
|
||||||
|
) : ChatItem
|
||||||
|
|
||||||
class ChatStore {
|
class ChatStore {
|
||||||
/** lane key -> chronological items. */
|
/** lane key -> chronological items. */
|
||||||
private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(emptyMap())
|
private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(emptyMap())
|
||||||
@@ -219,6 +236,7 @@ class ChatStore {
|
|||||||
TYPE_TOOL_END -> onToolEnd(lane, frame)
|
TYPE_TOOL_END -> onToolEnd(lane, frame)
|
||||||
TYPE_COMMENTARY -> onCommentary(lane, frame)
|
TYPE_COMMENTARY -> onCommentary(lane, frame)
|
||||||
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
|
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
|
||||||
|
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,6 +618,60 @@ class ChatStore {
|
|||||||
if (changed) _lanes.value = map
|
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<PickerChoicePayload>() ?: 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). */
|
/** M5: mark the user message [messageId] as read (read.receipt). */
|
||||||
fun markRead(messageId: String) {
|
fun markRead(messageId: String) {
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
@@ -684,6 +756,7 @@ class ChatStore {
|
|||||||
when (item) {
|
when (item) {
|
||||||
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else 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 MessageItem -> if (item.streaming) item.copy(streaming = false) else item
|
||||||
|
is PickerItem -> item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (updated != list) {
|
if (updated != list) {
|
||||||
@@ -788,6 +861,8 @@ class ChatStore {
|
|||||||
// A resolved ts of 0 means the anchor itself is ts-less
|
// A resolved ts of 0 means the anchor itself is ts-less
|
||||||
// (lane start) — sort with it (end) instead of to the top.
|
// (lane start) — sort with it (end) instead of to the top.
|
||||||
is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||||
|
|
||||||
|
is PickerItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ const val TYPE_SEARCH_RESULTS = "search.results"
|
|||||||
|
|
||||||
// Slash-command catalog (the composer's "/" drawer)
|
// Slash-command catalog (the composer's "/" drawer)
|
||||||
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
|
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 = "sync"
|
||||||
const val TYPE_SYNC_DONE = "sync.done"
|
const val TYPE_SYNC_DONE = "sync.done"
|
||||||
const val TYPE_HISTORY = "history"
|
const val TYPE_HISTORY = "history"
|
||||||
@@ -413,6 +417,27 @@ data class CommandsCatalogPayload(
|
|||||||
val commands: List<SlashCommand> = emptyList(),
|
val commands: List<SlashCommand> = 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<PickerChoice> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -640,6 +665,24 @@ fun searchFrame(
|
|||||||
* Answered by a `commands.catalog` frame carrying the same id. */
|
* Answered by a `commands.catalog` frame carrying the same id. */
|
||||||
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
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(
|
fun syncFrame(
|
||||||
id: Int,
|
id: Int,
|
||||||
cursor: Long,
|
cursor: Long,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import iris.protocol.TYPE_MESSAGE_START
|
|||||||
import iris.protocol.TYPE_MESSAGE_STOP
|
import iris.protocol.TYPE_MESSAGE_STOP
|
||||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||||
import iris.protocol.TYPE_NOTIFICATION
|
import iris.protocol.TYPE_NOTIFICATION
|
||||||
|
import iris.protocol.TYPE_PICKER_CHOICE
|
||||||
import iris.protocol.TYPE_READ_RECEIPT
|
import iris.protocol.TYPE_READ_RECEIPT
|
||||||
import iris.protocol.TYPE_SEARCH_RESULTS
|
import iris.protocol.TYPE_SEARCH_RESULTS
|
||||||
import iris.protocol.TYPE_STATUS
|
import iris.protocol.TYPE_STATUS
|
||||||
@@ -71,6 +72,7 @@ import iris.protocol.channelSetDefaultFrame
|
|||||||
import iris.protocol.commandsCatalogFrame
|
import iris.protocol.commandsCatalogFrame
|
||||||
import iris.protocol.historyFrame
|
import iris.protocol.historyFrame
|
||||||
import iris.protocol.messageDeleteFrame
|
import iris.protocol.messageDeleteFrame
|
||||||
|
import iris.protocol.pickerSelectFrame
|
||||||
import iris.protocol.searchFrame
|
import iris.protocol.searchFrame
|
||||||
import iris.protocol.syncFrame
|
import iris.protocol.syncFrame
|
||||||
import iris.ui.theme.Backdrop
|
import iris.ui.theme.Backdrop
|
||||||
@@ -570,6 +572,12 @@ class IrisController(
|
|||||||
_searching.value = false
|
_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 -> {
|
TYPE_COMMANDS_CATALOG -> {
|
||||||
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
|
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
|
||||||
}
|
}
|
||||||
@@ -803,6 +811,18 @@ class IrisController(
|
|||||||
|
|
||||||
// ── M3: channel directory ops (server is authoritative) ───────────────
|
// ── 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) {
|
fun createChannel(name: String) {
|
||||||
val trimmed = name.trim()
|
val trimmed = name.trim()
|
||||||
if (trimmed.isEmpty()) return
|
if (trimmed.isEmpty()) return
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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.MediaItem
|
||||||
import iris.data.MessageItem
|
import iris.data.MessageItem
|
||||||
import iris.data.MsgStatus
|
import iris.data.MsgStatus
|
||||||
|
import iris.data.PickerItem
|
||||||
import iris.data.ToolItem
|
import iris.data.ToolItem
|
||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.ImageFilePicker
|
import iris.platform.ImageFilePicker
|
||||||
@@ -610,6 +612,12 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
is ToolItem -> {
|
is ToolItem -> {
|
||||||
if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
|
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"
|
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 ──────────────────────────────────────────────────
|
// ── Slash-command drawer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Max rows in the slash drawer (the rest is reachable by typing more). */
|
/** Max rows in the slash drawer (the rest is reachable by typing more). */
|
||||||
|
|||||||
@@ -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<PickerItem>(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<PickerItem>(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<PickerItem>(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<PickerItem>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,16 @@ In-app banner (foreground) and/or push mirror (background).
|
|||||||
|
|
||||||
Interactive prompts. App renders a native picker; answers via `picker.select`.
|
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
|
```json
|
||||||
{"type":"picker.model","chat_id":"…","payload":{
|
{"type":"picker.model","chat_id":"…","payload":{
|
||||||
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
|
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
|
||||||
|
|||||||
@@ -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." } } },
|
"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.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" } } },
|
"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": {
|
"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" } } },
|
"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)." } } },
|
"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." } } },
|
"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" } } },
|
"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" } } }
|
"ping": { "payload": { "ts": { "type": "integer" } } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,11 +101,9 @@
|
|||||||
},
|
},
|
||||||
"x-planned-frames": [
|
"x-planned-frames": [
|
||||||
{ "name": "picker.model", "direction": "server_to_app", "note": "Model/provider picker prompt. Planned, not implemented." },
|
{ "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.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.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.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": "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": "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)." },
|
{ "name": "agent.busy", "direction": "server_to_app", "note": "Agent-busy indicator. Planned, not implemented (typing frames cover it)." },
|
||||||
|
|||||||
@@ -562,6 +562,10 @@ def _mint_message_id() -> str:
|
|||||||
return f"m_{uuid.uuid4().hex[:16]}"
|
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:
|
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return None
|
return None
|
||||||
@@ -1212,6 +1216,10 @@ class IrisAdapter(BasePlatformAdapter):
|
|||||||
# last finalized assistant message id per chat (offer association).
|
# last finalized assistant message id per chat (offer association).
|
||||||
self._media = media_bridge.MediaStore(get_hermes_home())
|
self._media = media_bridge.MediaStore(get_hermes_home())
|
||||||
self._last_message_id: dict[str, str] = {}
|
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
|
# M5: push backend (FCM primary, ntfy fallback) + the throttle for
|
||||||
# the outbox-prune banner.
|
# the outbox-prune banner.
|
||||||
self._push: PushBackend = build_push_backend(
|
self._push: PushBackend = build_push_backend(
|
||||||
@@ -2725,6 +2733,40 @@ class IrisAdapter(BasePlatformAdapter):
|
|||||||
return
|
return
|
||||||
logger.info("iris: push tokens updated for %s", device_id)
|
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 ────────────────────────────────────
|
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||||
|
|
||||||
async def send_slash_confirm(
|
async def send_slash_confirm(
|
||||||
@@ -2757,6 +2799,43 @@ class IrisAdapter(BasePlatformAdapter):
|
|||||||
chat_id, title, message, session_key, confirm_id, metadata=metadata
|
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(
|
async def send_clarify(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -2873,7 +2952,7 @@ class IrisAdapter(BasePlatformAdapter):
|
|||||||
"push_ntfy_server": (
|
"push_ntfy_server": (
|
||||||
self._push.server_url if isinstance(self._push, NtfyBackend) else ""
|
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]]:
|
def channel_list(self) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) ->
|
|||||||
await adapter.on_message_delete(frame, device_id)
|
await adapter.on_message_delete(frame, device_id)
|
||||||
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
||||||
await adapter.on_fcm_register(frame, device_id)
|
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).
|
# Unknown types are ignored (forward-compat).
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ TYPE_SEARCH_RESULTS = "search.results"
|
|||||||
# Slash-command catalog (app's "/" drawer)
|
# Slash-command catalog (app's "/" drawer)
|
||||||
TYPE_COMMANDS_CATALOG = "commands.catalog"
|
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)
|
# Reconnect catch-up (M3 outbox; extended by M5 push)
|
||||||
TYPE_SYNC = "sync"
|
TYPE_SYNC = "sync"
|
||||||
TYPE_SYNC_DONE = "sync.done"
|
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)
|
# Sync frames (M3 outbox)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ def plugin():
|
|||||||
def adapter(plugin, monkeypatch):
|
def adapter(plugin, monkeypatch):
|
||||||
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
|
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
|
||||||
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
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
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||||
|
|
||||||
# Platform("iris") resolves only once the platform is registered
|
# Platform("iris") resolves only once the platform is registered
|
||||||
@@ -2398,3 +2405,87 @@ async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client):
|
|||||||
assert c["name"].startswith("/")
|
assert c["name"].startswith("/")
|
||||||
assert "description" in c and "args_hint" in c and "category" in c
|
assert "description" in c and "args_hint" in c and "category" in c
|
||||||
assert isinstance(c["aliases"], list)
|
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
|
||||||
Reference in new issue
Block a user