Hold an image's place, open it full screen, and fold a run of calls

Five changes to how a transcript reads.

**Images no longer move the page.** The row was as tall as whatever had
loaded, so it grew when the bytes arrived and pushed everything below it
-- and in a bottom-anchored list, an image loading above the viewport
moved the text under the reader's eyes. The height is now decided before
the fetch and never changes: four lines of the body style, measured from
the type so it stays four lines when the reader has scaled their fonts.
Nothing to see when loading finishes, which is the point.

**A small image is enlarged with nearest neighbour**, a large one shrunk
smoothly -- decided per image from its actual size rather than set once,
since blowing a 16px sprite up with interpolation turns it into a blur
of exactly the thing being looked at.

**Tapping one opens it full screen**, fitted so the whole image is
visible first, with two-finger zoom to 8x and pan once zoomed. A dialog
rather than a screen, so back returns to the transcript.

**A tool call is one line closed**: the tool's name and what the call is
for. The command is not on it, because a wrapped command turns one row
into four. Open, it shows the command, the rest of the input and the
output, with the timeout at the top right -- a limit on the call rather
than part of what it does, worth seeing beside the command it constrains.
A call waiting on permission is shown open regardless, since the command
is the thing being decided.

**Adjacent calls fold into "Called n tools"**, closed by default, and it
closes again from either end -- a long group's heading scrolls away while
its last call is still on screen, and the reader who wants it shut is
looking at the bottom. The calls keep their full width; what says they
belong together is the surface behind them, one cue rather than two
half-cues. Grouping happens at display time, not in the fold: the
transcript's own order is what paging and the stream depend on.

Echo gains `/tools [n]` so a run of calls can be produced without paying
for one.

Verified on the emulator: four calls folded and expanded, one opened
inside the group showing `timeout 5000` top right, a 16px checkerboard
enlarged with hard pixel edges beside a shrunk screenshot at the same
height, the screen byte-identical between one second and six after
opening, full screen fitted, and back returning to the same scroll
position. Pinch itself is the one thing not verified here -- `adb input`
cannot inject a two-finger gesture.

53 tests, clippy, rustfmt, Android lint and ktfmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 13:43:46 -04:00
1 parent 8fe13634cb
commit 011ed0d0e1
5 files changed
+559 -141

No files matched your search

@@ -0,0 +1,262 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A run of consecutive tool calls, or anything else, in the order they will be drawn.
*
* Grouping is decided here rather than when events are folded, because it is a display decision:
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it.
*/
sealed class TranscriptRow {
data class Single(val item: TranscriptItem) : TranscriptRow()
/** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
/** Stable across reloads because it is the first call's own id. */
val id: String
get() = calls.first().id
}
}
/**
* Runs of adjacent tool calls become one row; everything else passes through.
*
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
* and the run this exists for is the burst of five greps nobody wants to scroll past.
*/
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>()
fun flush() {
when (run.size) {
0 -> {}
1 -> rows += TranscriptRow.Single(run.first())
else -> rows += TranscriptRow.Tools(run.toList())
}
run = mutableListOf()
}
items.forEach { item ->
if (item is TranscriptItem.ToolRun) run += item
else {
flush()
rows += TranscriptRow.Single(item)
}
}
flush()
return rows
}
/**
* Several calls under one heading, closed until somebody asks.
*
* The calls keep their own full width -- no indent, no inset -- because they are the same rows they
* would be on their own, and stepping them in would say they are something lesser. What says they
* belong together is the surface behind them, which is the one cue rather than two half-cues.
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom, not hunting for the top.
*/
@Composable
fun ToolGroup(
group: TranscriptRow.Tools,
expanded: Boolean,
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onAnswer: (TranscriptItem.ToolRun, String) -> Unit,
) {
if (!expanded) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(12.dp),
)
}
return
}
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow)) {
Text(
"Called ${group.calls.size} tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(12.dp),
)
group.calls.forEach { call ->
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) },
onAnswer = { answer -> onAnswer(call, answer) },
)
}
CollapseBar(onToggle)
}
}
/** The bottom half of a group's toggle: an arrow back up to its heading. */
@Composable
private fun CollapseBar(onToggle: () -> Unit) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth()
.clickable(onClick = onToggle)
.semantics { contentDescription = "Collapse these tool calls" }
.padding(vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
) {
// Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a
// system font may simply not have, and the reader who gets the empty box is never me.
androidx.compose.foundation.Canvas(Modifier.width(20.dp).height(10.dp)) {
val inset = 2.dp.toPx()
drawLine(
colour,
Offset(inset, size.height - inset),
Offset(size.width / 2, inset),
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
drawLine(
colour,
Offset(size.width / 2, inset),
Offset(size.width - inset, size.height - inset),
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
}
/**
* One tool call.
*
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall -- and the
* name plus the intent is what somebody scanning the transcript is reading for.
*
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does, and it is worth
* seeing beside the command it constrains rather than buried in the fields below it.
*
* A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on.
*/
@Composable
fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (String) -> Unit,
) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.ask != null && tool.ask.answer == null
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
if (open) {
Spacer(Modifier.weight(1f))
parsed.timeout?.let {
Text(
"timeout $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
parsed.title?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
} ?: Spacer(Modifier.weight(1f))
}
if (!tool.done) {
Spacer(Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (open) {
parsed.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
Text(tool.output, style = MaterialTheme.typography.bodySmall)
}
}
tool.ask?.let { ask -> PermissionAsk(ask, onAnswer) }
}
}
}
/**
* The permission ask on the call it is about.
*
* Only the question, not the prompt's second half: the backend sends the tool's input with it so
* the ask can stand alone, and here it does not have to -- the card above is 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) }
}
}
}
}