Carry a question in the event model, not in one provider's JSON

A question is now fully described by the event that reports it: the tag
it was asked under, each option's label, what it means, and the sample of
what picking it would produce, plus whether several may be picked at
once. The app renders from that alone.

It had been reading Claude Code's tool input to find the parts the event
dropped -- that dialect's schema, written out a second time in Kotlin,
where no other provider could reach it and where it would drift the
first time the schema moved. Echo could not describe an option at all,
and llama never will.

Answers travel as a list for the same reason. A question that takes one
answer sends a list of one rather than being a different shape, and the
one place that flattens it is where the CLI is spoken to: its answers
map holds a string, so several choices are joined there. That join was
in the phone.

Also here because it is the same rule: the permission ask reuses the
question body rather than owning a second one, so Allow/Deny renders and
resolves through exactly the code an AskUserQuestion does.

Verified against both, since a refactor that only satisfies the case it
was written for has been tried on the half that cannot fail: a two
question `/ask` answered from the phone, one option and then two, and a
real sonnet session's `rm -f` permission asked, allowed, and run.
This commit is contained in:
iris committed 2026-08-29 16:46:43 -04:00
1 parent fea8e7e92b
commit bebaae7a94
13 files changed
+419 -237

No files matched your search

@@ -121,8 +121,13 @@ sealed class TranscriptItem {
override val seq: Long,
val id: String,
val prompt: String,
val options: List<String>,
val answer: String?,
/** A few words naming what this is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
@@ -207,8 +212,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
entry.seq,
event.id,
event.prompt,
event.header,
event.options,
null,
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
@@ -229,12 +236,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answer = event.answer)
it.copy(answers = event.answers)
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answer = event.answer) else ask
if (ask.id == event.id) ask.copy(answers = event.answers)
else ask
}
)
else -> it
@@ -806,8 +814,10 @@ fun SessionScreen(
if (id in expandedTools) expandedTools - id
else expandedTools + id
},
onAnswer = { questionId, answer ->
act { answerQuestion(settings, summary.id, questionId, answer) }
onAnswer = { questionId, answers ->
act {
answerQuestion(settings, summary.id, questionId, answers)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
@@ -825,22 +835,22 @@ fun SessionScreen(
expandedTools - item.id
else expandedTools + item.id
},
onAnswer = { questionId, answer ->
onAnswer = { questionId, answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answer,
answers,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answer ->
QuestionRow(item) { answers ->
act {
answerQuestion(settings, summary.id, item.id, answer)
answerQuestion(settings, summary.id, item.id, answers)
}
}
is TranscriptItem.ErrorMsg ->
@@ -1025,24 +1035,15 @@ private fun UserBubble(text: String, pending: Boolean = false) {
* connected device.
*/
@Composable
private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
private fun QuestionRow(
question: TranscriptItem.QuestionCard,
onAnswer: (List<String>) -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(question.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
if (question.answer != null) {
Text(
"Answered: ${question.answer}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// Wrapped, not in a Row. A Row hands out intrinsic widths in order and clips
// whatever runs past the edge, so a question with four options showed the first
// one or two and silently dropped the rest off the side of the screen -- which
// does not look like a bug, it looks like those were the only choices.
AnswerOptions(question.options, onAnswer)
}
// The same body the questions on a tool call get: one question is the same
// thing whether or not something else asked it.
AskedQuestion(question, onAnswer)
}
}
}