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:
1 parent
8fe13634cb
commit
011ed0d0e1
5 files changed
+559
-141
No files matched your search
@@ -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<ImageBitmap?>(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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(null) }
|
||||
var input by remember { mutableStateOf("") }
|
||||
var expandedTools by remember { mutableStateOf(setOf<String>()) }
|
||||
// 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<String>()) }
|
||||
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||
// 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<ImageBitmap?>(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
|
||||
|
||||
@@ -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<String>,
|
||||
)
|
||||
) {
|
||||
/** 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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user