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>
183 lines
7.2 KiB
Kotlin
183 lines
7.2 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.unit.dp
|
|
import dev.snipme.highlights.Highlights
|
|
import dev.snipme.highlights.model.BoldHighlight
|
|
import dev.snipme.highlights.model.ColorHighlight
|
|
import dev.snipme.highlights.model.SyntaxLanguage
|
|
import org.json.JSONObject
|
|
|
|
/**
|
|
* A tool call's input, read rather than dumped.
|
|
*
|
|
* Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"…",
|
|
* "timeout":120000}` themselves to find the one line they care about. So the fields that carry the
|
|
* meaning are pulled out -- the command a shell will run, what it is for, how long it may take --
|
|
* and anything left over is still shown, because dropping a field would be claiming the tool has no
|
|
* other input when it might.
|
|
*/
|
|
data class ToolInput(
|
|
/** The thing that will actually be run or read, if this tool has one. */
|
|
val subject: String?,
|
|
/** The language [subject] is written in, for highlighting. */
|
|
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.
|
|
*
|
|
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
|
|
* from being the special case that gets its own code path. Unknown tools fall through to "no
|
|
* subject, everything is rest", which is what the card always did.
|
|
*/
|
|
private val SUBJECTS: Map<String, Pair<String, SyntaxLanguage?>> =
|
|
mapOf(
|
|
"Bash" to ("command" to SyntaxLanguage.SHELL),
|
|
"Read" to ("file_path" to null),
|
|
"Write" to ("file_path" to null),
|
|
"Edit" to ("file_path" to null),
|
|
"Glob" to ("pattern" to null),
|
|
"Grep" to ("pattern" to null),
|
|
"WebFetch" to ("url" to null),
|
|
)
|
|
|
|
/** Fields that are the tool's own prose about itself rather than input to it. */
|
|
private val DESCRIPTIONS = listOf("description", "prompt")
|
|
|
|
fun parseToolInput(tool: String, input: String): ToolInput {
|
|
val json =
|
|
try {
|
|
JSONObject(input)
|
|
} catch (_: org.json.JSONException) {
|
|
// Not an object: older transcripts and some tools send a bare
|
|
// string. It is still the input, so it is still shown.
|
|
return ToolInput(
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(),
|
|
)
|
|
}
|
|
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
|
|
val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
|
|
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, timeout, rest)
|
|
}
|
|
|
|
/** A tool call's input: its subject highlighted, its description, then whatever else it carried. */
|
|
@Composable
|
|
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
|
|
val parsed = remember(tool, input) { parseToolInput(tool, input) }
|
|
Column(modifier.fillMaxWidth()) {
|
|
parsed.subject?.let { subject ->
|
|
// Not wrapped: a wrapped command hides where its arguments end,
|
|
// and the long one is the one being read closely.
|
|
Text(
|
|
highlighted(subject, parsed.language),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
softWrap = false,
|
|
modifier =
|
|
Modifier.padding(top = 4.dp)
|
|
.fillMaxWidth()
|
|
.horizontalScroll(rememberScrollState()),
|
|
)
|
|
}
|
|
parsed.rest.forEach {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(top = 2.dp),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* [code] with its keywords and strings coloured, or plain if there is no language for it.
|
|
*
|
|
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
|
|
* a library's default theme would be the one place in the app whose palette came from somewhere
|
|
* else.
|
|
*/
|
|
@Composable
|
|
private fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString {
|
|
val theme = catppuccinSyntax()
|
|
val plain = MaterialTheme.colorScheme.onSurface
|
|
return remember(code, language, theme, plain) {
|
|
if (language == null) return@remember AnnotatedString(code)
|
|
val marks =
|
|
Highlights.Builder(code = code, language = language, theme = theme)
|
|
.build()
|
|
.getHighlights()
|
|
buildAnnotatedString {
|
|
append(code)
|
|
marks.forEach { mark ->
|
|
when (mark) {
|
|
is ColorHighlight ->
|
|
addStyle(
|
|
SpanStyle(
|
|
color =
|
|
androidx.compose.ui.graphics.Color(
|
|
mark.rgb or 0xFF000000.toInt()
|
|
)
|
|
),
|
|
mark.location.start,
|
|
mark.location.end,
|
|
)
|
|
is BoldHighlight ->
|
|
addStyle(
|
|
SpanStyle(fontWeight = FontWeight.Bold),
|
|
mark.location.start,
|
|
mark.location.end,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|