diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt new file mode 100644 index 0000000..b81caee --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt @@ -0,0 +1,169 @@ +package com.example.aiapp + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * An image in the transcript: a fixed-height thumbnail that opens full screen. + * + * The height is decided before the bytes arrive and never changes. An image row that grew when it + * finished loading pushed everything below it, so a transcript being read scrolled itself while + * somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport + * moved the text under the reader's eyes. Reserving the final height makes loading invisible, which + * is what it should be. + * + * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather + * than as a page of its own. Full size is one tap away. + */ +@Composable +fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { + var bitmap by remember(ref) { mutableStateOf(null) } + var failed by remember(ref) { mutableStateOf(false) } + var full by remember(ref) { mutableStateOf(false) } + LaunchedEffect(ref) { + try { + val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) } + bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() + failed = bitmap == null + } catch (_: ApiException) { + failed = true + } + } + val height = thumbnailHeight() + val heightPx = with(LocalDensity.current) { height.roundToPx() } + Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { + when (val image = bitmap) { + null -> + Text( + // Two states, not one: an image still arriving and an image that will never + // arrive look nothing alike to a reader who can do something about the second. + if (failed) "[image $ref unavailable]" else "[loading image…]", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> + Image( + bitmap = image, + contentDescription = "Attached image, tap to view full screen", + contentScale = ContentScale.Fit, + filterQuality = enlargingFilter(image.height, heightPx), + modifier = Modifier.fillMaxSize().clickable { full = true }, + alignment = Alignment.CenterStart, + ) + } + } + if (full) bitmap?.let { image -> ImageViewer(image) { full = false } } +} + +/** + * Four lines of the body style the transcript is set in. + * + * Measured from the type rather than written as a dp, so it stays four lines when the text size + * changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded + * height would be wrong. + */ +@Composable +private fun thumbnailHeight(): Dp { + val line = MaterialTheme.typography.bodyLarge.lineHeight + val density = LocalDensity.current + return remember(line, density) { + with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp } + } +} + +/** + * Nearest neighbour when the image is being enlarged, smooth when it is being shrunk. + * + * A small image blown up with interpolation turns into a blur that hides what it is -- the same + * image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision + * per image rather than a preference set once. + */ +private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality = + if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High + +/** + * The image on its own, as large as it fits, with pinch to zoom. + * + * A dialog rather than a screen, so the platform's back gesture returns to the transcript instead + * of leaving the app. It opens fitted -- the whole image visible, which is the thing a reader wants + * first -- and zoom is theirs from there. + */ +@Composable +private fun ImageViewer(image: ImageBitmap, onClose: () -> Unit) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + Box( + Modifier.fillMaxSize() + .background(Color.Black) + .clickable(onClick = onClose) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + // Floor of 1 so the image cannot be pinched smaller than fitted, which is + // already the whole of it; a ceiling so it cannot be lost off-screen. + scale = (scale * zoom).coerceIn(1f, 8f) + if (scale > 1f) { + offsetX += pan.x + offsetY += pan.y + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = image, + contentDescription = "Attached image", + contentScale = ContentScale.Fit, + // Zoomed in, the reader is looking at pixels on purpose. + filterQuality = FilterQuality.None, + modifier = + Modifier.fillMaxSize().graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + }, + ) + } + } +} 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 d50ddc4..664a144 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1,11 +1,9 @@ package com.example.aiapp -import android.graphics.BitmapFactory import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,8 +43,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -191,6 +187,9 @@ fun SessionScreen( var actionError by remember { mutableStateOf(null) } var input by remember { mutableStateOf("") } var expandedTools by remember { mutableStateOf(setOf()) } + // Which runs of adjacent tool calls are open. Keyed by the first call's + // id, so a group survives more calls arriving after it. + var expandedGroups by remember { mutableStateOf(setOf()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } // What this session is set to now, seeded from the row that opened it and @@ -224,6 +223,9 @@ fun SessionScreen( var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } val listState = rememberLazyListState() + // What is actually drawn: the transcript with runs of adjacent tool + // calls folded into one row each. + val rows = remember(items) { groupToolRuns(items) } fun apply(entry: SeqEvent) { lastSeq.set(entry.seq) @@ -578,42 +580,80 @@ fun SessionScreen( } // Reversed to match the layout, so index 0 is the newest and // the reader still sees them in the order they happened. - items(items.asReversed()) { item -> - when (item) { - is TranscriptItem.UserMsg -> UserBubble(item.text) - is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, - onAnswer = { answer -> - item.ask?.let { ask -> + // Grouped first: adjacent tool calls collapse into one row, + // which is a decision about this screen and not about the + // transcript the stream and paging share. + items(rows.asReversed()) { row -> + when (row) { + is TranscriptRow.Tools -> + ToolGroup( + group = row, + expanded = row.id in expandedGroups, + onToggle = { + expandedGroups = + if (row.id in expandedGroups) expandedGroups - row.id + else expandedGroups + row.id + }, + isToolExpanded = { it in expandedTools }, + onToolToggle = { id -> + expandedTools = + if (id in expandedTools) expandedTools - id + else expandedTools + id + }, + onAnswer = { call, answer -> + call.ask?.let { ask -> act { answerQuestion(settings, summary.id, ask.id, answer) } } }, - onToggle = { - expandedTools = - if (item.id in expandedTools) expandedTools - item.id - else expandedTools + item.id - }, ) - is TranscriptItem.QuestionCard -> - QuestionRow(item) { answer -> - act { answerQuestion(settings, summary.id, item.id, answer) } + is TranscriptRow.Single -> + when (val item = row.item) { + is TranscriptItem.UserMsg -> UserBubble(item.text) + is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) + is TranscriptItem.ToolRun -> + ToolCard( + tool = item, + expanded = item.id in expandedTools, + onToggle = { + expandedTools = + if (item.id in expandedTools) + expandedTools - item.id + else expandedTools + item.id + }, + onAnswer = { answer -> + item.ask?.let { ask -> + act { + answerQuestion( + settings, + summary.id, + ask.id, + answer, + ) + } + } + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answer -> + act { + answerQuestion(settings, summary.id, item.id, answer) + } + } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> + SessionImage(settings, summary.id, item.ref) + is TranscriptItem.Note -> + Text( + item.text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } } @@ -716,35 +756,6 @@ fun SessionScreen( * An inline transcript image, fetched (authenticated, pinned) from the session's files route. The * bitmap is remembered per ref, so scrolling doesn't refetch. */ -@Composable -private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { - var bitmap by remember(ref) { mutableStateOf(null) } - var failed by remember(ref) { mutableStateOf(false) } - LaunchedEffect(ref) { - try { - val bytes = withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) } - bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() - failed = bitmap == null - } catch (_: ApiException) { - failed = true - } - } - when (val image = bitmap) { - null -> - Text( - if (failed) "[image $ref unavailable]" else "[loading image…]", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - else -> - Image( - bitmap = image, - contentDescription = "session image", - modifier = Modifier.fillMaxWidth(), - ) - } -} - @Composable private fun UserBubble(text: String, pending: Boolean = false) { Box(Modifier.fillMaxWidth()) { @@ -776,71 +787,6 @@ private fun UserBubble(text: String, pending: Boolean = false) { * Collapsed by default: name plus a spinner while running, expandable to the input and output. The * spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet". */ -@Composable -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) { - Text( - tool.tool, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - if (!tool.done) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp).height(16.dp), - strokeWidth = 2.dp, - ) - } - } - // 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("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) } - } - } - } -} /** * A question (or permission request -- same shape) inline in the transcript. Option buttons until 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 1427d34..002a644 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -38,9 +38,18 @@ data class ToolInput( val language: SyntaxLanguage?, /** The tool's own one-line summary, when it wrote one. */ val description: String?, + /** + * How long the call may take, as the tool expressed it. Shown apart because it is a limit on + * the call rather than part of what the call does. + */ + val timeout: String?, /** Everything else, as `name: value` lines. Never dropped. */ val rest: List, -) +) { + /** The one line to show when there is only room for one: what this call is for. */ + val title: String? + get() = description ?: subject +} /** * Which field of which tool is the subject. @@ -74,6 +83,7 @@ fun parseToolInput(tool: String, input: String): ToolInput { null, null, null, + null, input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(), ) } @@ -82,16 +92,18 @@ fun parseToolInput(tool: String, input: String): ToolInput { val description = DESCRIPTIONS.firstNotNullOfOrNull { json.optString(it).takeIf { v -> v.isNotBlank() } } + val timeout = json.optString("timeout").takeIf { it.isNotBlank() } val rest = json .keys() .asSequence() .filter { it != subjectKey || subject == null } .filter { it !in DESCRIPTIONS || description == null } + .filter { it != "timeout" || timeout == null } .sorted() .map { key -> "$key: ${json.get(key)}" } .toList() - return ToolInput(subject, language, description, rest) + return ToolInput(subject, language, description, timeout, rest) } /** A tool call's input: its subject highlighted, its description, then whatever else it carried. */ @@ -99,13 +111,6 @@ fun parseToolInput(tool: String, input: String): ToolInput { 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt new file mode 100644 index 0000000..3e1661f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -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) : 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): List { + val rows = mutableListOf() + var run = mutableListOf() + + 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) } + } + } + } +} diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 2db2735..ba75d4c 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -7,6 +7,8 @@ //! A leading word asks for something more specific: //! //! - `/tool [input]` -- a full tool run, start through end. +//! - `/tools [n]` -- n calls back to back, for what a run of them looks +//! like when a screen groups them. //! - `/question [text]` -- a question, exercising the answer path. //! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only //! exist *while* something is happening can be looked at. @@ -108,9 +110,20 @@ impl Driver for EchoDriver { return; } - let run_tool = text - .strip_prefix("/tool") - .map(|rest| rest.trim().to_string()); + // Checked before `/tool`, which is a prefix of it: matching the + // shorter one first would read "/tools 4" as a single tool whose + // input is "s 4". + let many_tools = text.strip_prefix("/tools").map(|rest| { + // At least two, because one call is not a run of them and this + // exists to produce a run. + rest.trim().parse::().unwrap_or(3).clamp(2, 12) + }); + let run_tool = if many_tools.is_some() { + None + } else { + text.strip_prefix("/tool") + .map(|rest| rest.trim().to_string()) + }; // Seconds to stay running before answering, default 30. Clamped // rather than trusted: this is a test affordance, and a session // pinned running for an hour by a typo is a worse outcome than a @@ -187,6 +200,29 @@ impl Driver for EchoDriver { return; } + if let Some(count) = many_tools { + for i in 1..=count { + let id = format!("t-{}", super::random_hex()); + send(Event::ToolStart { + id: id.clone(), + tool: if i % 2 == 0 { "Read" } else { "Bash" }.to_string(), + input: serde_json::json!({ + "command": format!("grep -rn 'call {i}' /tmp | head -3"), + "file_path": format!("/tmp/call-{i}.txt"), + "description": format!("The {i} of {count} calls in this run"), + "timeout": 5000, + }), + }); + tokio::time::sleep(DELTA_DELAY).await; + send(Event::ToolEnd { + id, + output: format!("call {i} finished"), + }); + } + finish(); + return; + } + if let Some(input) = run_tool { let id = format!("t-{}", super::random_hex()); send(Event::ToolStart {