From c5dbd1535d45fd7c81681fbe5b2d835468ccd688 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 06:24:05 -0400 Subject: [PATCH] Ask for permission on the call it is about, and read the input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/androidApp/build.gradle.kts | 1 + .../main/kotlin/com/example/aiapp/Events.kt | 9 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 94 ++++++++-- .../main/kotlin/com/example/aiapp/Theme.kt | 23 +++ .../kotlin/com/example/aiapp/ToolInput.kt | 160 ++++++++++++++++++ app/gradle/libs.versions.toml | 5 + server/src/session/claude/translate.rs | 15 ++ server/src/session/driver.rs | 9 + server/src/session/echo.rs | 1 + server/src/session/transcript.rs | 1 + 10 files changed, 302 insertions(+), 16 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index d41fbc4..2310db2 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -145,4 +145,5 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.zxing.embedded) implementation(libs.markdown.renderer) + implementation(libs.highlights) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 9c1ff09..9a4c337 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -23,7 +23,13 @@ sealed class SessionEvent { data class Image(val ref: String) : SessionEvent() - data class Question(val id: String, val prompt: String, val options: List) : + data class Question( + val id: String, + val prompt: String, + val options: List, + /** The tool call this is permission for, or null when it is not about one. */ + val about: String?, + ) : SessionEvent() data class Answered(val id: String, val answer: String) : SessionEvent() @@ -66,6 +72,7 @@ fun parseSeqEvent(json: String): SeqEvent { body.getJSONArray("options").let { options -> (0 until options.length()).map { options.getString(it) } }, + about = body.optString("about").ifEmpty { null }, ) "answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer")) "status" -> SessionEvent.Status(body.getString("state")) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index b01a892..1e28b2d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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, event: SessionEvent): List - 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) } } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 18e4129..d7b1e31 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -4,6 +4,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import dev.snipme.highlights.model.SyntaxTheme /** * Catppuccin Mocha, as published in `catppuccin/palette`. @@ -124,6 +126,27 @@ val warningColor: Color val codeColor: Color @Composable get() = Mocha.Green +/** + * Catppuccin Mocha as a syntax theme, for the highlighter used on a tool call's input. + * + * Here with the rest of the palette rather than beside the code that highlights: a library's own + * theme would otherwise be the one surface in the app whose colours came from somewhere else, and + * the accents below are the same ones every other coloured thing already uses. + */ +fun catppuccinSyntax(): SyntaxTheme = + SyntaxTheme( + key = "catppuccin-mocha", + code = Mocha.Text.toArgb(), + keyword = Mocha.Mauve.toArgb(), + string = Mocha.Green.toArgb(), + literal = Mocha.Peach.toArgb(), + comment = Mocha.Overlay0.toArgb(), + metadata = Mocha.Yellow.toArgb(), + multilineComment = Mocha.Overlay0.toArgb(), + punctuation = Mocha.Subtext0.toArgb(), + mark = Mocha.Sky.toArgb(), + ) + /** * A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt new file mode 100644 index 0000000..8fedf62 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -0,0 +1,160 @@ +package com.example.aiapp + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.snipme.highlights.Highlights +import dev.snipme.highlights.model.BoldHighlight +import dev.snipme.highlights.model.ColorHighlight +import dev.snipme.highlights.model.SyntaxLanguage +import org.json.JSONObject + +/** + * A tool call's input, read rather than dumped. + * + * Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"…", + * "timeout":120000}` themselves to find the one line they care about. So the fields that carry the + * meaning are pulled out -- the command a shell will run, what it is for, how long it may take -- + * and anything left over is still shown, because dropping a field would be claiming the tool has + * no other input when it might. + */ +data class ToolInput( + /** The thing that will actually be run or read, if this tool has one. */ + val subject: String?, + /** The language [subject] is written in, for highlighting. */ + val language: SyntaxLanguage?, + /** The tool's own one-line summary, when it wrote one. */ + val description: String?, + /** Everything else, as `name: value` lines. Never dropped. */ + val rest: List, +) + +/** + * Which field of which tool is the subject. + * + * A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them + * from being the special case that gets its own code path. Unknown tools fall through to "no + * subject, everything is rest", which is what the card always did. + */ +private val SUBJECTS: Map> = + mapOf( + "Bash" to ("command" to SyntaxLanguage.SHELL), + "Read" to ("file_path" to null), + "Write" to ("file_path" to null), + "Edit" to ("file_path" to null), + "Glob" to ("pattern" to null), + "Grep" to ("pattern" to null), + "WebFetch" to ("url" to null), + ) + +/** Fields that are the tool's own prose about itself rather than input to it. */ +private val DESCRIPTIONS = listOf("description", "prompt") + +fun parseToolInput(tool: String, input: String): ToolInput { + val json = + try { + JSONObject(input) + } catch (_: org.json.JSONException) { + // Not an object: older transcripts and some tools send a bare + // string. It is still the input, so it is still shown. + return ToolInput(null, null, null, input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty()) + } + val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) + val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() } + val description = DESCRIPTIONS.firstNotNullOfOrNull { json.optString(it).takeIf { v -> v.isNotBlank() } } + val rest = + json.keys() + .asSequence() + .filter { it != subjectKey || subject == null } + .filter { it !in DESCRIPTIONS || description == null } + .sorted() + .map { key -> "$key: ${json.get(key)}" } + .toList() + return ToolInput(subject, language, description, rest) +} + +/** A tool call's input: its subject highlighted, its description, then whatever else it carried. */ +@Composable +fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { + val parsed = remember(tool, input) { parseToolInput(tool, input) } + Column(modifier.fillMaxWidth()) { + parsed.description?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + parsed.subject?.let { subject -> + // Not wrapped: a wrapped command hides where its arguments end, + // and the long one is the one being read closely. + Text( + highlighted(subject, parsed.language), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + softWrap = false, + modifier = + Modifier.padding(top = 4.dp).fillMaxWidth().horizontalScroll(rememberScrollState()), + ) + } + parsed.rest.forEach { + Text( + it, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** + * [code] with its keywords and strings coloured, or plain if there is no language for it. + * + * The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] -- + * a library's default theme would be the one place in the app whose palette came from somewhere + * else. + */ +@Composable +private fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString { + val theme = catppuccinSyntax() + val plain = MaterialTheme.colorScheme.onSurface + return remember(code, language, theme, plain) { + if (language == null) return@remember AnnotatedString(code) + val marks = + Highlights.Builder(code = code, language = language, theme = theme).build().getHighlights() + buildAnnotatedString { + append(code) + marks.forEach { mark -> + when (mark) { + is ColorHighlight -> + addStyle( + SpanStyle(color = androidx.compose.ui.graphics.Color(mark.rgb or 0xFF000000.toInt())), + mark.location.start, + mark.location.end, + ) + is BoldHighlight -> + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + mark.location.start, + mark.location.end, + ) + } + } + } + } +} diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index 25d886f..4d16b50 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -19,6 +19,10 @@ zxing-embedded = "4.3.0" # time. Latest stable, checked 2026-08-29 against Maven Central -- 0.27.0 # exists only as release candidates. markdown-renderer = "0.26.0" +# Syntax highlighting for a tool call's input. Same reasoning as the markdown +# renderer: a language's lexical rules are somebody else's specification. +# Latest stable, checked 2026-08-29 against Maven Central. +highlights = "1.0.0" # Declared rather than inherited for the same reason as core-ktx: SessionScreen # now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event # stream open only while the screen is on screen. Latest stable, checked @@ -47,6 +51,7 @@ desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref # The -m3 flavour: it takes its colours and type from the ambient Material 3 # theme, so the app's Catppuccin scheme is what it draws with. markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } +highlights = { module = "dev.snipme:highlights", version.ref = "highlights" } # Declared directly rather than through the plugin's `compose.*` accessors, # which are deprecated as of CMP 1.11. compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index 45cbf88..843430a 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -185,6 +185,13 @@ impl Translator { .and_then(Value::as_str) .unwrap_or("a tool"); let input = request.get("input").cloned().unwrap_or(Value::Null); + // Measured, not matched: the request names the call it is about, so + // the phone never has to guess which tool row a permission belongs + // to by comparing inputs. + let about = request + .get("tool_use_id") + .and_then(Value::as_str) + .map(String::from); let mut events = Vec::new(); let mut questions = Vec::new(); @@ -213,6 +220,8 @@ impl Translator { id: format!("{request_id}#{i}"), prompt: text.clone(), options, + // A question the model asked, not permission for a call. + about: None, }); questions.push(text); } @@ -223,6 +232,7 @@ impl Translator { id: request_id.clone(), prompt: format!("Allow {tool_name}?\n{summary}"), options: vec!["Allow".to_string(), "Deny".to_string()], + about: about.clone(), }); } self.pending.insert( @@ -463,11 +473,15 @@ mod tests { id, prompt, options, + about, } = &events[0] else { panic!("expected a question, got {events:?}"); }; assert_eq!(id, "req-1"); + // The call being asked about, so the phone draws the ask on that + // tool's row instead of as a second card repeating its input. + assert_eq!(about.as_deref(), Some("toolu_03")); assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x")); assert_eq!(options, &["Allow", "Deny"]); assert_eq!( @@ -528,6 +542,7 @@ mod tests { id, prompt, options, + .. } => Some((id.clone(), prompt.clone(), options.clone())), _ => None, }) diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 262a834..e594c93 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -73,6 +73,15 @@ pub enum Event { id: String, prompt: String, options: Vec, + /// The tool call this is permission for, when it is one. + /// + /// The CLI's `can_use_tool` request carries the `tool_use_id` of + /// the call it is asking about, so a phone can draw the ask on the + /// tool's own row rather than as a second card repeating its + /// input. `None` for anything that is not about a tool -- + /// AskUserQuestion, and an echo session's question. + #[serde(default, skip_serializing_if = "Option::is_none")] + about: Option, }, /// The manager's record of a question being answered, so a rendered /// question card resolves on every device, not just the one that diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 366a242..2db2735 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -100,6 +100,7 @@ impl Driver for EchoDriver { id, prompt, options: vec!["Yes".to_string(), "No".to_string()], + about: None, }); self.emit(Event::Status { state: SessionStatus::AwaitingInput, diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 489356d..8b0c886 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -337,6 +337,7 @@ mod tests { id: "q1".into(), prompt: "Allow?".into(), options: vec!["Yes".into(), "No".into()], + about: None, }, Event::Answered { id: "q1".into(),