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(),