Add interactive choice-picker menus for finite-choice slash commands
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s

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:
ARIAandClaude Opus 4.8 committed 2026-08-22 23:33:53 +02:00
1 parent 34a64d6e53
commit 82c5a20848
12 files changed
+565 -8

No files matched your search

@@ -36,7 +36,7 @@ class ChatDb(
* tool cards interleaved at their anchored position). */
fun loadLanes(): Map<String, List<ChatItem>> =
synchronized(lock) {
val messages = linkedMapOf<String, MutableList<MessageItem>>()
val messages = linkedMapOf<String, MutableList<ChatItem>>()
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<MessageItem>(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<PickerItem>(payload)
} catch (_: Exception) {
null
}
}
private fun decodeTool(payload: String): ToolItem? =
@@ -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<PickerChoice> = emptyList(),
val ts: Long = 0,
val selected: String? = null,
) : ChatItem
class ChatStore {
/** lane key -> chronological items. */
private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(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<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). */
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
}
}
}
@@ -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<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) ───────────────────────────────────────
@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,
@@ -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<CommandsCatalogPayload>()?.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
@@ -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). */