From e76617e2107dcced5717511290801532c9d7b011 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 30 Aug 2026 19:49:47 -0400 Subject: [PATCH] Draw verbatim text on its own dark surface A command, a tool's output and a code block in a reply are the one thing on this screen that is not somebody's prose, and they now say so: Mocha's Crust, which sits below Base, so the same colour is one clear step down both on the page where a reply is drawn and on the card where a tool call is. The renderer's code background was `surfaceVariant`, which is exactly a card's own fill -- a fenced block inside a tool call had no background at all, and one in a reply read as a step *up* out of the page. Tool output takes the monospace face with it. It is column-aligned far more often than it is prose -- a listing, a diff, a table of numbers -- and a proportional font silently destroys the alignment that carried the meaning. `RawBlock` is a composable rather than a modifier because the inset is part of it: monospace text against the edge of a tinted block reads as clipping. A call with neither a subject nor any other field draws nothing at all rather than an empty tinted rectangle. Co-Authored-By: Claude Opus 5 --- .../main/kotlin/com/example/aiapp/Markdown.kt | 8 +++- .../main/kotlin/com/example/aiapp/RawBlock.kt | 38 +++++++++++++++++++ .../main/kotlin/com/example/aiapp/Theme.kt | 16 ++++++++ .../kotlin/com/example/aiapp/ToolInput.kt | 20 ++++++---- .../main/kotlin/com/example/aiapp/ToolRows.kt | 13 ++++++- 5 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 3ed092e..0764e7e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -39,8 +39,12 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi markdownColor( text = MaterialTheme.colorScheme.onSurface, dividerColor = MaterialTheme.colorScheme.outlineVariant, - codeBackground = MaterialTheme.colorScheme.surfaceVariant, - inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant, + // The dark surface every verbatim thing in this app sits on -- see [rawSurface], + // and the tool call above this reply, which now matches. `surfaceVariant` was + // exactly a card's own fill, so a fenced block inside a tool call had no + // background at all and one in a reply read as a step *up* out of the page. + codeBackground = rawSurface, + inlineCodeBackground = rawSurface, // The same tint a code block gets, rather than the renderer's 2%-alpha default: // two adjacent tints that differ by a fiftieth read as one flat block on a phone, // so the table would have had a border-less grid and nothing saying where it began. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt new file mode 100644 index 0000000..201847d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt @@ -0,0 +1,38 @@ +package com.example.aiapp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +/** + * Verbatim text, on the surface that says so: a command about to be run, what a tool printed. + * + * A composable rather than a modifier repeated at each site, because the inset is part of it -- + * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three + * copies of "clip, fill, pad" drift apart the first time one of them is adjusted. + * + * The colour is [rawSurface], which is also what a code block inside a reply is given; that is the + * point of having one name for it. Markdown's blocks are painted by the renderer rather than by + * this, since it draws its own, but they are the same colour on purpose. + */ +@Composable +fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier + .fillMaxWidth() + // Smaller than a card's radius, and deliberately: this sits *inside* one, and a + // rounded rectangle drawn at the same radius as the rounded rectangle behind it reads + // as a misprint rather than as nesting. + .clip(MaterialTheme.shapes.extraSmall) + .background(rawSurface) + .padding(horizontal = 8.dp, vertical = 6.dp), + content = content, + ) +} 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 9d084dc..9a27a7e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -181,6 +181,22 @@ private const val WARNING_PERCENT = 75.0 /** Close enough that the next turn may be the one that is refused. */ private const val OVER_LIMIT_PERCENT = 90.0 +/** + * The surface verbatim text sits on: a command, a tool's output, a code block in a reply. + * + * The darkest value in the palette rather than a step up from the page, and that is the whole point + * -- everything else on this screen is somebody's prose, and this is what a machine was handed and + * what it said back, character for character. Crust sits *below* Base, so the same colour reads as + * one clear step down both on the page, where a reply is drawn, and on a card, where a tool call + * is; a tint chosen upwards has to be picked twice and still collides with the card it lands on. + * The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill + * -- so a code block inside a tool call had no background at all. + * + * One colour for all three, so "this is verbatim" is learnable once. + */ +val rawSurface: Color + @Composable get() = Mocha.Crust + /** * Catppuccin Mocha as a syntax theme, for the highlighter used on a tool call's input. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 002a644..e402a83 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -1,7 +1,6 @@ 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 @@ -106,11 +105,21 @@ fun parseToolInput(tool: String, input: String): ToolInput { return ToolInput(subject, language, description, timeout, rest) } -/** A tool call's input: its subject highlighted, its description, then whatever else it carried. */ +/** + * A tool call's input: its subject highlighted, then whatever else it carried. + * + * On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing + * at all when the call carried neither, rather than as an empty block: a tinted rectangle with + * nothing in it is a rendering fault, and it is the shape a tool with no input actually has. + * + * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs + * with the reader's text rather than inside the machine's; [ToolCard] draws it above this. + */ @Composable fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { val parsed = remember(tool, input) { parseToolInput(tool, input) } - Column(modifier.fillMaxWidth()) { + if (parsed.subject == null && parsed.rest.isEmpty()) return + RawBlock(modifier) { parsed.subject?.let { subject -> // Not wrapped: a wrapped command hides where its arguments end, // and the long one is the one being read closely. @@ -119,10 +128,7 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, softWrap = false, - modifier = - Modifier.padding(top = 4.dp) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) } parsed.rest.forEach { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 580ed8a..807e221 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -375,7 +376,17 @@ fun ToolCard( if (tool.output.isNotEmpty()) { Spacer(Modifier.height(8.dp)) Text("Output", style = MaterialTheme.typography.labelSmall) - Text(tool.output, style = MaterialTheme.typography.bodySmall) + // What the tool printed, on the surface everything verbatim gets and in the + // face it was written for: this is column-aligned far more often than it is + // prose -- a directory listing, a diff, a table of numbers -- and a + // proportional font silently destroys the alignment that carried the meaning. + RawBlock(Modifier.padding(top = 2.dp)) { + Text( + tool.output, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } } } // Shown open or closed. A call that produced a picture is one