Being open did two things to grouping, and only one of them was wanted. It held a call standing on its own out of the run it belongs to, so a command finishing behind the card being read no longer shuts it and folds it away mid-sentence. It also took a call *out* of the group it was already inside, and that is what made collapsing jump: grouping is what gives a row its identity, so one tap rebuilt the rows around the finger -- opening a call inside a group split the group into two pieces with mismatched keys, and closing one replaced three rows with one, which no anchor survives. Measured at 450px of jump, with the card that was closed going with it. So the held-out set is now the screen's, not the transcript's: a call that has never been drawn inside a group and is open stands out of its run, and a call that has been in one stays in it whatever the reader does to it. Being inside a group once is a fact about what the reader has been shown, which is why the screen is what remembers it. Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest, and on the emulator against the sandbox: opening a call inside an open group of six leaves it one group of six and closing it returns every row to the pixel it came from; a call opened while standing alone survives a reply landing behind it, and folds back into "Called 3 tools" when it is closed without moving the rows below it.
495 lines
22 KiB
Kotlin
495 lines
22 KiB
Kotlin
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.foundation.shape.CornerBasedShape
|
|
import androidx.compose.foundation.shape.CornerSize
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.Immutable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.graphics.Shape
|
|
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
|
|
|
|
/**
|
|
* One row as the transcript draws it: a run of consecutive tool calls, or anything else.
|
|
*
|
|
* 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.
|
|
*
|
|
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
|
|
* than edited, and two rows describing the same events are equal. Compose infers stability from a
|
|
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
|
|
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
|
|
* measured as 701 compositions for 148 rows in one scroll.
|
|
*
|
|
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
|
|
*/
|
|
@Immutable
|
|
sealed class TranscriptRow {
|
|
/**
|
|
* This row's identity in the list, which must survive everything that can happen to the row.
|
|
*
|
|
* The list is keyed by this so that inserting a new message at one end, or a page of history at
|
|
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
|
|
* and the transcript steps under whoever is reading it.
|
|
*
|
|
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
|
|
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
|
|
* that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where
|
|
* [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing
|
|
* each piece its key.
|
|
*/
|
|
abstract val key: Any
|
|
|
|
/**
|
|
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
|
|
*
|
|
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
|
|
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
|
|
* every device. So anything that has to point at a place in the conversation and still find it
|
|
* later -- a saved scroll position -- points with this.
|
|
*/
|
|
abstract val startSeq: Long
|
|
|
|
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
|
|
TranscriptRow() {
|
|
override val startSeq: Long
|
|
get() = item.seq
|
|
}
|
|
|
|
/** Two or more calls with nothing between them; drawn as one collapsed card. */
|
|
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
|
|
TranscriptRow() {
|
|
override val startSeq: Long
|
|
get() = calls.first().seq
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* The last call is left alone too, and so is one still running wherever in its run it sits. What
|
|
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
|
|
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
|
|
* being overtaken: anything arriving behind it, a reply included, makes it history.
|
|
*
|
|
* [heldOut] is the one thing being read can change, and only in that direction: a call standing on
|
|
* its own that somebody is reading is not overtaken while they read it. Opening a call *already*
|
|
* inside a group does not pull it out (2026-09-16, after it briefly did) -- it is visible where it
|
|
* is, and grouping is what gives a row its identity, so a rule that reads the open set both ways
|
|
* makes the reader's own tap rebuild the rows around it: three rows became one the moment a call
|
|
* was closed, and no anchor survives a row that no longer exists -- the list jumped by 450px and
|
|
* took the closed card with it. Which calls are held out is [SessionScreen]'s to say, since being
|
|
* inside a group once is what settles it.
|
|
*/
|
|
fun groupToolRuns(
|
|
items: List<TranscriptItem>,
|
|
heldOut: Set<String> = emptySet(),
|
|
): List<TranscriptRow> = DebugStats.timed("grouped tool runs") { groupRuns(items, heldOut) }
|
|
|
|
private fun groupRuns(items: List<TranscriptItem>, heldOut: Set<String>): List<TranscriptRow> {
|
|
val rows = mutableListOf<TranscriptRow>()
|
|
var run = mutableListOf<TranscriptItem.ToolRun>()
|
|
// A run can occupy more than one non-adjacent piece, so claimed keys span the whole transcript
|
|
// rather than resetting at each piece.
|
|
var runId: String? = null
|
|
val claimedKeys = mutableSetOf<String>()
|
|
|
|
fun flush() {
|
|
val first = run.firstOrNull() ?: return
|
|
// The first piece keeps the run's name, which survives a page landing in front of it
|
|
// ([adoptRun]). Later pieces qualify that name with their first call; the suffix is the
|
|
// final guard because a duplicate LazyColumn key takes down the whole screen.
|
|
var key = first.runId
|
|
if (!claimedKeys.add(key)) {
|
|
key = "${first.runId}/${first.id}"
|
|
var suffix = 2
|
|
while (!claimedKeys.add(key)) {
|
|
key = "${first.runId}/${first.id}/${suffix++}"
|
|
}
|
|
}
|
|
rows +=
|
|
if (run.size == 1) TranscriptRow.Single(first, key)
|
|
else TranscriptRow.Tools(run.toList(), key)
|
|
run = mutableListOf()
|
|
}
|
|
|
|
items.forEachIndexed { index, item ->
|
|
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
|
|
// Adjacency is the same answer most of the time and a worse one at the edges: a call
|
|
// arriving next to an existing run, or a page of history arriving in front of one, both
|
|
// change which call is *first*.
|
|
val call = item as? TranscriptItem.ToolRun
|
|
if (call == null || call.runId != runId) {
|
|
flush()
|
|
runId = call?.runId
|
|
}
|
|
when {
|
|
call == null -> rows += TranscriptRow.Single(item)
|
|
// Standing outside the run is the call's place in the list as it is now, not something
|
|
// recorded on the call: the same finished call is a row of its own while it is the last
|
|
// thing that happened, or open and never yet grouped, and part of its group once a
|
|
// reply lands behind it.
|
|
call.done && call.id !in heldOut && index != items.lastIndex -> run += call
|
|
else -> {
|
|
flush()
|
|
run += call
|
|
flush()
|
|
}
|
|
}
|
|
}
|
|
flush()
|
|
return rows
|
|
}
|
|
|
|
/**
|
|
* Several calls under one heading, closed until somebody asks.
|
|
*
|
|
* What says the calls belong together is the surface behind them, which is the one cue rather than
|
|
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
|
|
* one object rather than as a square patch behind round things. The calls sit on it inset by
|
|
* [GROUP_INSET], which is the container's own padding rather than an indent.
|
|
*
|
|
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
|
|
* the run reads as one thing broken into its parts; see [connectedShape].
|
|
*
|
|
* 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. The bar at the foot is the
|
|
* same height as the heading at the top.
|
|
*/
|
|
@Composable
|
|
fun ToolGroup(
|
|
group: TranscriptRow.Tools,
|
|
expanded: Boolean,
|
|
/**
|
|
* Where it was pressed is the row's business rather than the control's -- a group has a control
|
|
* at each end, and only the row knows where its own ends are.
|
|
*/
|
|
onToggle: () -> Unit,
|
|
isToolExpanded: (String) -> Boolean,
|
|
onToolToggle: (String) -> Unit,
|
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
image: @Composable (String) -> Unit,
|
|
) {
|
|
val heading = "Called ${group.calls.size} tools"
|
|
if (!expanded) {
|
|
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
|
Text(
|
|
heading,
|
|
style = MaterialTheme.typography.titleSmall,
|
|
modifier = Modifier.padding(GROUP_INSET_LARGE),
|
|
)
|
|
}
|
|
return
|
|
}
|
|
Column(
|
|
Modifier.fillMaxWidth()
|
|
.clip(MaterialTheme.shapes.medium)
|
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
|
) {
|
|
val barHeight = groupBarHeight()
|
|
Row(
|
|
Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Text(
|
|
heading,
|
|
style = MaterialTheme.typography.titleSmall,
|
|
modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE),
|
|
)
|
|
}
|
|
Column(
|
|
Modifier.padding(horizontal = GROUP_INSET),
|
|
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
|
|
) {
|
|
group.calls.forEachIndexed { index, call ->
|
|
ToolCard(
|
|
tool = call,
|
|
expanded = isToolExpanded(call.id),
|
|
onToggle = { onToolToggle(call.id) },
|
|
onAnswer = onAnswer,
|
|
image = image,
|
|
shape = connectedShape(index, group.calls.size),
|
|
)
|
|
}
|
|
}
|
|
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
|
|
// and what they are looking at is what follows it.
|
|
CollapseBar(barHeight, onToggle)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The height of a group's heading, and so of the bar at its foot.
|
|
*
|
|
* Derived from the type the heading is set in rather than written down, because the two have to
|
|
* match and a pair of numbers chosen to look equal stops being equal the moment the density
|
|
* changes.
|
|
*/
|
|
@Composable
|
|
private fun groupBarHeight(): Dp {
|
|
val line = MaterialTheme.typography.titleSmall.lineHeight
|
|
return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2
|
|
}
|
|
|
|
/**
|
|
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
|
|
* rather than padded to something that looks close, so the surface the calls sit on is the same
|
|
* thickness at both ends.
|
|
*/
|
|
@Composable
|
|
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
|
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
|
Row(
|
|
Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics {
|
|
contentDescription = "Collapse these tool calls"
|
|
},
|
|
horizontalArrangement = Arrangement.Center,
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Chevron(Pointing.Up, colour = colour)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it
|
|
* does not.
|
|
*
|
|
* Written once and given an index rather than branched at each end, because a stack has three cases
|
|
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
|
|
*/
|
|
@Composable
|
|
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
|
|
val shape = MaterialTheme.shapes.medium
|
|
val square = CornerSize(0.dp)
|
|
return shape.copy(
|
|
topStart = if (index == 0) shape.topStart else square,
|
|
topEnd = if (index == 0) shape.topEnd else square,
|
|
bottomStart = if (index == count - 1) shape.bottomStart else square,
|
|
bottomEnd = if (index == count - 1) shape.bottomEnd else square,
|
|
)
|
|
}
|
|
|
|
/** The padding inside a card, and so the height a bar of one line of text comes to. */
|
|
private val GROUP_INSET_LARGE = 12.dp
|
|
|
|
/** How far the stack of calls is held off the edge of the surface it sits on. */
|
|
private val GROUP_INSET = 4.dp
|
|
|
|
/** Enough to read the join as a join rather than as one tall card. */
|
|
private val GROUP_GAP = 2.dp
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
image: @Composable (String) -> Unit = {},
|
|
/** Square where this card faces another in a group; see [connectedShape]. */
|
|
shape: Shape = CardDefaults.shape,
|
|
) {
|
|
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
|
|
val name = toolDisplayName(tool.tool)
|
|
val output = toolDisplayOutput(tool.tool, tool.output)
|
|
val deciding = tool.asks.any { it.answers.isEmpty() }
|
|
val open = expanded || deciding
|
|
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
|
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
Text(name, 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))
|
|
}
|
|
// A spinner says the machine is working. While this call is waiting on an answer
|
|
// the machine is doing nothing at all -- the turn is stopped on the person reading
|
|
// it -- so it says whose move it is instead.
|
|
if (deciding) {
|
|
Spacer(Modifier.width(8.dp))
|
|
Text(
|
|
"your turn",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = awaitingColor,
|
|
)
|
|
} else 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),
|
|
)
|
|
}
|
|
// Everything AskUserQuestion carries is the questions, and those are drawn below as
|
|
// something answerable; dumping the same JSON above them would be the decision
|
|
// stated twice, once unreadably.
|
|
if (tool.tool != ASK_USER_QUESTION) {
|
|
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
|
|
}
|
|
if (output.isNotEmpty()) {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text("Output", style = MaterialTheme.typography.labelSmall)
|
|
// 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, and a proportional font silently destroys the alignment that carried
|
|
// the meaning.
|
|
//
|
|
// Its terminal styling applied and the rest of the escapes taken out: colour is
|
|
// often the whole of what a diff or a test run is saying. Remembered against
|
|
// the text, so a card that is open through a scroll parses once.
|
|
val palette = remember { ansiPalette() }
|
|
val styled = remember(output, palette) { ansiStyled(output, palette) }
|
|
RawBlock(Modifier.padding(top = 2.dp)) {
|
|
Text(
|
|
styled,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// Shown open or closed. A call that produced a picture is one whose result *is* the
|
|
// picture, and a row that hides it says less than the one line it replaced.
|
|
tool.images.forEach { ref -> image(ref) }
|
|
if (tool.asks.isNotEmpty()) {
|
|
if (tool.tool == ASK_USER_QUESTION) {
|
|
AskUserQuestionBody(tool.asks, onAnswer)
|
|
} else {
|
|
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private val collaborationToolNames =
|
|
mapOf(
|
|
"Task" to "Spawn agent",
|
|
"TaskOutput" to "Wait for agents",
|
|
"SendMessage" to "Message agent",
|
|
"CloseAgent" to "Close agent",
|
|
"InterruptAgent" to "Interrupt agent",
|
|
"ListAgents" to "List agents",
|
|
"ResumeAgent" to "Resume agent",
|
|
)
|
|
|
|
internal fun toolDisplayName(tool: String): String = collaborationToolNames[tool] ?: tool
|
|
|
|
internal fun toolDisplayOutput(tool: String, output: String): String =
|
|
if (tool in collaborationToolNames && output == "completed") "" else output
|
|
|
|
/**
|
|
* 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: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
) {
|
|
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
|
|
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
|
|
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
|
|
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
|
|
// back.
|
|
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
ask.prompt.substringBefore('\n'),
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = awaitingColor,
|
|
)
|
|
// Answered or not, the options stay and the one that was taken is marked -- see
|
|
// [AskedQuestion], which is the same rule on the question card. A permission is where it
|
|
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
|
|
val settled = ask.answers.isNotEmpty()
|
|
AnswerOptions(
|
|
ask.options,
|
|
if (settled) ask.answers else listOfNotNull(pressed),
|
|
onPick =
|
|
if (settled || pressed != null) null
|
|
else
|
|
{ label ->
|
|
pressed = label
|
|
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
|
|
},
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The tool whose input is a question rather than a command; see [AskUserQuestionBody].
|
|
*
|
|
* Also what [runIdFor] breaks a run of calls on, so the row a reader answered is never folded
|
|
* inside a collapsed group.
|
|
*/
|
|
const val ASK_USER_QUESTION = "AskUserQuestion"
|