Ask for permission on the call it is about, and read the input

A bash permission request arrived as a second card repeating the tool
call's input verbatim, so the same command appeared twice and the reader
had to work out it was one event. `Event::Question` now carries `about`:
the `tool_use_id` the CLI's `can_use_tool` request already names. That
makes the pairing a measured fact rather than a match on input text --
and it stays `Option`, because AskUserQuestion is not permission for
anything and an echo session's question is about no tool at all. Those
still draw as their own card, which is what every question did before.

The card also reads the input instead of dumping it. Every tool's input
is JSON, and showing it raw makes the reader parse `{"command":"…",
"timeout":5000}` to find the line they care about. A small table says
which field is the subject of which tool -- Bash's `command`, Read's
`file_path` -- and the rest is still listed, since dropping a field
would claim the tool had no other input when it might. The subject is
syntax-highlighted with dev.snipme:highlights, for the reason the
markdown renderer is a library: lexical rules are somebody else's
specification. Its theme is Catppuccin, mapped in Theme.kt beside the
rest of the palette rather than taken from the library's defaults.

The input shows whether or not the card is expanded. A row that says
only "Bash" says nothing anyone can act on, least of all when it is
asking to run something.

Verified on the emulator against a real haiku session: one card, the
description, `grep -rn "needle" /tmp | head -3` highlighted, `timeout:
5000` pulled out, and "Allow Bash?" with its buttons inside the card --
then Allow, which resolved in place and ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 06:24:05 -04:00
1 parent 206319045d
commit c5dbd1535d
10 files changed
+302 -16

No files matched your search

@@ -79,6 +79,15 @@ sealed class TranscriptItem {
val input: String,
val output: String,
val done: Boolean,
/**
* The permission ask for this call, when there is one.
*
* On the call's own row rather than beside it: the ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a permission is about, so this
* is a fact rather than a match on the input.
*/
val ask: QuestionCard? = null,
) : TranscriptItem()
data class QuestionCard(
@@ -124,14 +133,29 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
} else {
items + TranscriptItem.ToolRun(event.id, "tool", "", event.output, done = true)
}
is SessionEvent.Question ->
items +
TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null)
is SessionEvent.Question -> {
val card = TranscriptItem.QuestionCard(event.id, event.prompt, event.options, null)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
// own, which is what every question was before this.
if (event.about != null && items.any { it is TranscriptItem.ToolRun && it.id == event.about }) {
updateTool(items, event.about) { it.copy(ask = card) }
} else {
items + card
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool
// row's ask. Missing the second left an Allow/Deny pair live on
// a question already answered from another device.
items.map {
if (it is TranscriptItem.QuestionCard && it.id == event.id)
it.copy(answer = event.answer)
else it
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answer = event.answer)
it is TranscriptItem.ToolRun && it.ask?.id == event.id ->
it.copy(ask = it.ask.copy(answer = event.answer))
else -> it
}
}
is SessionEvent.Status -> items
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
@@ -559,6 +583,11 @@ fun SessionScreen(
ToolCard(
tool = item,
expanded = item.id in expandedTools,
onAnswer = { answer ->
item.ask?.let { ask ->
act { answerQuestion(settings, summary.id, ask.id, answer) }
}
},
onToggle = {
expandedTools =
if (item.id in expandedTools) expandedTools - item.id
@@ -745,7 +774,12 @@ private fun UserBubble(text: String, pending: Boolean = false) {
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
*/
@Composable
private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle: () -> Unit) {
private fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (String) -> Unit,
) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -761,15 +795,45 @@ private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle:
)
}
}
if (expanded) {
// Always, not only when expanded: what a call is doing is the
// command, and a row saying "Bash" says nothing a reader can act
// on -- least of all when it is asking for permission to run it.
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
tool.ask?.let { ask -> PermissionAsk(ask, onAnswer) }
if (expanded && tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Input", style = MaterialTheme.typography.labelSmall)
Text(tool.input, style = MaterialTheme.typography.bodySmall)
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
Text(tool.output, style = MaterialTheme.typography.bodySmall)
}
Text("Output", style = MaterialTheme.typography.labelSmall)
Text(tool.output, style = MaterialTheme.typography.bodySmall)
}
}
}
}
/**
* The permission ask on the call it is about.
*
* Only the question is shown, not the prompt's second half: the backend sends the tool's input
* along with it so the ask can stand alone, and here it does not have to -- the card above is
* already showing exactly that.
*/
@Composable
private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
Spacer(Modifier.height(8.dp))
Text(
ask.prompt.substringBefore('\n'),
style = MaterialTheme.typography.bodyMedium,
color = awaitingColor,
)
if (ask.answer != null) {
Text(
"Answered: ${ask.answer}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ask.options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
}
}
}