Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
359649bc73
26 files changed
+1361
-271
No files matched your search
@@ -37,12 +37,20 @@
|
||||
<profileable android:shell="true" tools:targetApi="q" />
|
||||
<!-- adjustResize (not the system's default pan): the layout handles
|
||||
the keyboard itself via imePadding(), so the window must resize
|
||||
rather than slide the top bar off screen. -->
|
||||
rather than slide the top bar off screen.
|
||||
|
||||
stateUnchanged: coming back to the app leaves the keyboard as it
|
||||
was left. The default, stateUnspecified, lets the system decide,
|
||||
and what it decides with a focused message field is to open the
|
||||
keyboard, so switching away and back covered half the transcript
|
||||
somebody had switched away to compare against. Unchanged rather
|
||||
than hidden, because a keyboard that was up when the app was left
|
||||
is one somebody was in the middle of typing into. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:windowSoftInputMode="adjustResize|stateUnchanged"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
/**
|
||||
* The sixteen colours a terminal program names, and the two it assumes.
|
||||
*
|
||||
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
|
||||
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
|
||||
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
|
||||
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
|
||||
* this list would silently move `ls`'s directory blue.
|
||||
*/
|
||||
data class AnsiPalette(
|
||||
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
|
||||
val colours: List<Color>,
|
||||
/** What uncoloured text is, needed only where a style has to state a colour. */
|
||||
val foreground: Color,
|
||||
/** What the text sits on, needed for reverse video. */
|
||||
val background: Color,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a tool printed, with its terminal styling applied and everything else taken out.
|
||||
*
|
||||
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
|
||||
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
|
||||
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
|
||||
* of what a diff, a test run or a linter is saying.
|
||||
*
|
||||
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
|
||||
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
|
||||
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
|
||||
*
|
||||
* A carriage return is honoured the way a terminal honours it: what was written since the last line
|
||||
* break is thrown away and the line starts again. That is what makes a progress bar show its final
|
||||
* state rather than every state it passed through, which was tens of lines run together.
|
||||
*
|
||||
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
|
||||
* parsed rather than re-run on every recomposition of the card holding it.
|
||||
*/
|
||||
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
|
||||
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
|
||||
|
||||
val runs = mutableListOf<Run>()
|
||||
var sgr = Sgr.PLAIN
|
||||
var at = 0
|
||||
val plain = StringBuilder()
|
||||
|
||||
fun flush() {
|
||||
if (plain.isNotEmpty()) {
|
||||
runs.add(Run(plain.toString(), sgr.span(palette)))
|
||||
plain.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (at < text.length) {
|
||||
val c = text[at]
|
||||
when {
|
||||
c == ESC -> {
|
||||
flush()
|
||||
at =
|
||||
skipEscape(text, at) { params, final ->
|
||||
if (final == 'm') sgr = sgr.apply(params, palette)
|
||||
}
|
||||
}
|
||||
// A bare carriage return rewrites the line; one before a newline is the other half of
|
||||
// a Windows line ending and has nothing to rewrite.
|
||||
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
|
||||
flush()
|
||||
dropLine(runs)
|
||||
at++
|
||||
}
|
||||
c == '\r' -> at++
|
||||
// Everything printable, plus the two control characters that are layout rather than
|
||||
// terminal commands. A stray bell or backspace goes for the same reason a cursor
|
||||
// move does.
|
||||
c >= ' ' || c == '\n' || c == '\t' -> {
|
||||
plain.append(c)
|
||||
at++
|
||||
}
|
||||
else -> at++
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
return buildAnnotatedString {
|
||||
runs.forEach { run ->
|
||||
if (run.style == null) {
|
||||
append(run.text)
|
||||
} else {
|
||||
val pushed = pushStyle(run.style)
|
||||
append(run.text)
|
||||
pop(pushed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One stretch of text that shares a style. */
|
||||
private class Run(val text: String, val style: SpanStyle?)
|
||||
|
||||
/** Throws away everything written since the last line break, as a carriage return does. */
|
||||
private fun dropLine(runs: MutableList<Run>) {
|
||||
while (runs.isNotEmpty()) {
|
||||
val last = runs.removeAt(runs.size - 1)
|
||||
val breakAt = last.text.lastIndexOf('\n')
|
||||
if (breakAt >= 0) {
|
||||
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ESC = '\u001B'
|
||||
|
||||
private const val BELL = '\u0007'
|
||||
|
||||
/**
|
||||
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
|
||||
*
|
||||
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
|
||||
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
|
||||
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
|
||||
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
|
||||
*/
|
||||
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
|
||||
val next = text.getOrNull(at + 1) ?: return at + 1
|
||||
return when (next) {
|
||||
'[' -> {
|
||||
var end = at + 2
|
||||
while (end < text.length && text[end] !in CSI_FINAL) end++
|
||||
if (end >= text.length) {
|
||||
// Cut off mid-sequence, which is what a stream that has not finished arriving
|
||||
// looks like: drop the fragment rather than printing it, and the whole sequence
|
||||
// arrives with the next delta.
|
||||
text.length
|
||||
} else {
|
||||
onCsi(text.substring(at + 2, end), text[end])
|
||||
end + 1
|
||||
}
|
||||
}
|
||||
']',
|
||||
'P',
|
||||
'X',
|
||||
'^',
|
||||
'_' -> {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
|
||||
var end = at + 2
|
||||
while (end < text.length) {
|
||||
if (text[end] == BELL) return end + 1
|
||||
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
|
||||
end++
|
||||
}
|
||||
text.length
|
||||
}
|
||||
else -> at + 2
|
||||
}
|
||||
}
|
||||
|
||||
/** The bytes that end a CSI sequence. */
|
||||
private val CSI_FINAL = '@'..'~'
|
||||
|
||||
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
|
||||
private data class Sgr(
|
||||
val fg: Color?,
|
||||
val bg: Color?,
|
||||
val bold: Boolean,
|
||||
val dim: Boolean,
|
||||
val italic: Boolean,
|
||||
val underline: Boolean,
|
||||
val strike: Boolean,
|
||||
val reverse: Boolean,
|
||||
) {
|
||||
/** Null while nothing is set, so unstyled output costs no spans at all. */
|
||||
fun span(palette: AnsiPalette): SpanStyle? {
|
||||
if (this == PLAIN) return null
|
||||
val front = if (reverse) bg ?: palette.background else fg
|
||||
val back = if (reverse) fg ?: palette.foreground else bg
|
||||
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
|
||||
val stated = front ?: palette.foreground.takeIf { dim }
|
||||
return SpanStyle(
|
||||
color =
|
||||
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
|
||||
background = back ?: Color.Unspecified,
|
||||
fontWeight = if (bold) FontWeight.Bold else null,
|
||||
fontStyle = if (italic) FontStyle.Italic else null,
|
||||
textDecoration =
|
||||
when {
|
||||
underline && strike ->
|
||||
TextDecoration.combine(
|
||||
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
||||
)
|
||||
underline -> TextDecoration.Underline
|
||||
strike -> TextDecoration.LineThrough
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
|
||||
*
|
||||
* A code this does not model is ignored rather than reset from: the program meant something by
|
||||
* it, and starting again would also drop the codes beside it that are understood.
|
||||
*/
|
||||
fun apply(params: String, palette: AnsiPalette): Sgr {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
|
||||
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
|
||||
var state = this
|
||||
var at = 0
|
||||
while (at < codes.size) {
|
||||
val code = codes[at]
|
||||
state =
|
||||
when (code) {
|
||||
0 -> PLAIN
|
||||
1 -> state.copy(bold = true)
|
||||
2 -> state.copy(dim = true)
|
||||
3 -> state.copy(italic = true)
|
||||
4 -> state.copy(underline = true)
|
||||
7 -> state.copy(reverse = true)
|
||||
9 -> state.copy(strike = true)
|
||||
21,
|
||||
22 -> state.copy(bold = false, dim = false)
|
||||
23 -> state.copy(italic = false)
|
||||
24 -> state.copy(underline = false)
|
||||
27 -> state.copy(reverse = false)
|
||||
29 -> state.copy(strike = false)
|
||||
in 30..37 -> state.copy(fg = palette.colours[code - 30])
|
||||
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
|
||||
in 40..47 -> state.copy(bg = palette.colours[code - 40])
|
||||
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
|
||||
39 -> state.copy(fg = null)
|
||||
49 -> state.copy(bg = null)
|
||||
38,
|
||||
48 -> {
|
||||
val (colour, last) = extendedColour(codes, at, palette)
|
||||
at = last
|
||||
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
at++
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
companion object {
|
||||
val PLAIN =
|
||||
Sgr(
|
||||
fg = null,
|
||||
bg = null,
|
||||
bold = false,
|
||||
dim = false,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strike = false,
|
||||
reverse = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
|
||||
private const val DIM_ALPHA = 0.65f
|
||||
|
||||
/**
|
||||
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
|
||||
*
|
||||
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
|
||||
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
|
||||
* the same red.
|
||||
*/
|
||||
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
|
||||
when (codes.getOrNull(at + 1)) {
|
||||
5 -> {
|
||||
val n = codes.getOrNull(at + 2)
|
||||
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
|
||||
}
|
||||
2 -> {
|
||||
val r = codes.getOrNull(at + 2)
|
||||
val g = codes.getOrNull(at + 3)
|
||||
val b = codes.getOrNull(at + 4)
|
||||
if (r == null || g == null || b == null) null to at + 1
|
||||
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
|
||||
}
|
||||
else -> null to at + 1
|
||||
}
|
||||
|
||||
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
|
||||
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
|
||||
when {
|
||||
n < 0 -> palette.foreground
|
||||
n < 16 -> palette.colours[n]
|
||||
n < 232 -> {
|
||||
val i = n - 16
|
||||
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||
}
|
||||
n < 256 -> {
|
||||
val grey = 8 + (n - 232) * 10
|
||||
Color(grey, grey, grey)
|
||||
}
|
||||
else -> palette.foreground
|
||||
}
|
||||
|
||||
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
|
||||
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
|
||||
@@ -198,16 +198,21 @@ fun AppRoot(
|
||||
// row key. Only reachable since a notification can move straight from one session to
|
||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
||||
key(here.summary.id) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
// The gesture goes on a box around the screen rather than inside it, so it is the
|
||||
// outermost thing in the tree and everything within has already had its chance at
|
||||
// the drag. See [swipeBack]. No imePadding here, for the reason above.
|
||||
Box(Modifier.swipeBack(goToMain)) {
|
||||
SessionScreen(
|
||||
settings = current,
|
||||
summary = here.summary,
|
||||
onBack = goToMain,
|
||||
share = share,
|
||||
onShareTaken = { share = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
Box(Modifier.imePadding()) {
|
||||
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
||||
SpawnScreen(
|
||||
settings = current,
|
||||
onSpawned = { spawned ->
|
||||
@@ -218,7 +223,7 @@ fun AppRoot(
|
||||
)
|
||||
}
|
||||
is Screen.Settings ->
|
||||
Box(Modifier.imePadding()) {
|
||||
Box(Modifier.imePadding().swipeBack(goToMain)) {
|
||||
SettingsScreen(
|
||||
existing = current,
|
||||
onSaved = { saved ->
|
||||
|
||||
@@ -10,42 +10,174 @@ 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.rememberScrollState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** One question's answer on its way back, so a card can hand over several at once. */
|
||||
data class QuestionAnswer(val questionId: String, val answers: List<String>)
|
||||
|
||||
/**
|
||||
* Every question one tool call is waiting on.
|
||||
* What the reader has settled on for one question, before any of it is sent.
|
||||
*
|
||||
* Held here rather than inferred from the transcript, which is what made picking an option feel
|
||||
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and
|
||||
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
|
||||
* the natural response was to tap again.
|
||||
*
|
||||
* Picked options and typed words are one field each because they are alternatives rather than
|
||||
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
|
||||
* away and picking puts the words away, and there is never a draft that means two things.
|
||||
*/
|
||||
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
||||
val settled: Boolean
|
||||
get() = picked.isNotEmpty() || other.isNotBlank()
|
||||
|
||||
/**
|
||||
* What goes back, in the order the options were offered rather than the order they were tapped:
|
||||
* the reader is answering a list, and it should read back as that list.
|
||||
*/
|
||||
fun answers(options: List<QuestionOption>): List<String> =
|
||||
if (other.isNotBlank()) listOf(other.trim())
|
||||
else options.map { it.label }.filter { it in picked }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every question one tool call is waiting on, one at a time.
|
||||
*
|
||||
* All of it comes from the question events themselves -- what each option means, what picking it
|
||||
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
||||
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
||||
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
||||
*
|
||||
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
||||
* three questions with four options and a description each is several screens tall, so the reader
|
||||
* scrolls past the question they are answering to reach the button that sends it, and never sees
|
||||
* the whole of any one of them. Paged, each question is a screen and the count says how many are
|
||||
* left -- which is also what makes "not all of them are answered" something the reader can act on
|
||||
* rather than something to go hunting for.
|
||||
*
|
||||
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
||||
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
|
||||
* reader could not change their mind about the first after reading the third.
|
||||
*/
|
||||
@Composable
|
||||
fun AskUserQuestionBody(
|
||||
asks: List<TranscriptItem.QuestionCard>,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
) {
|
||||
// Seeded from what was already answered, so a card the reader comes back to shows their
|
||||
// answers rather than an empty draft over them.
|
||||
var drafts by
|
||||
remember(asks.map { it.id }) {
|
||||
mutableStateOf(
|
||||
asks.associate { ask ->
|
||||
ask.id to
|
||||
Draft(
|
||||
picked =
|
||||
ask.answers
|
||||
.filter { a -> ask.options.any { it.label == a } }
|
||||
.toSet(),
|
||||
other =
|
||||
ask.answers
|
||||
.firstOrNull { a -> ask.options.none { it.label == a } }
|
||||
.orEmpty(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
|
||||
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
|
||||
if (asks.isEmpty()) return
|
||||
val showing = asks[at.coerceIn(0, asks.size - 1)]
|
||||
val outstanding = asks.filter { it.answers.isEmpty() }
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
asks.forEach { ask ->
|
||||
if (asks.size > 1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
"Question ${at + 1} of ${asks.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Disabled at the ends rather than absent, so the pair keeps its place and the
|
||||
// reader can see that there is nothing further that way.
|
||||
MarkButton("Previous question", { at-- }, enabled = at > 0) {
|
||||
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
||||
}
|
||||
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
|
||||
Chevron(Pointing.Right, colour = LocalContentColor.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
AskedQuestion(
|
||||
showing,
|
||||
draft = drafts[showing.id] ?: Draft(),
|
||||
onDraft = { drafts = drafts + (showing.id to it) },
|
||||
)
|
||||
if (outstanding.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) }
|
||||
// Greyed until every question has an answer, because the tool is waiting on all of
|
||||
// them: a submit that sent two of three would leave the third one asked and the card
|
||||
// looking dealt with.
|
||||
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
||||
Button(
|
||||
onClick = {
|
||||
sending = true
|
||||
onAnswer(
|
||||
outstanding.map { ask ->
|
||||
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
|
||||
}
|
||||
) {
|
||||
// Back to a button whatever happened. A refusal is reported by the screen
|
||||
// around this, and the draft is still here to send again -- a spinner
|
||||
// that never stops would be the only sign of a failure this card cannot
|
||||
// describe.
|
||||
sending = false
|
||||
}
|
||||
},
|
||||
enabled = ready && !sending,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (sending) {
|
||||
// In the button rather than beside it, so the row does not change height at
|
||||
// the moment it is pressed.
|
||||
CircularProgressIndicator(
|
||||
Modifier.height(18.dp).width(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = LocalContentColor.current,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,9 +188,16 @@ fun AskUserQuestionBody(
|
||||
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
|
||||
* when nothing did. A question is the same thing either way, and two renderings of it would be two
|
||||
* places for an answer to go missing.
|
||||
*
|
||||
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
|
||||
* sends anything. An answered question ignores both and draws what was answered.
|
||||
*/
|
||||
@Composable
|
||||
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
|
||||
fun AskedQuestion(
|
||||
ask: TranscriptItem.QuestionCard,
|
||||
draft: Draft,
|
||||
onDraft: (Draft) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
ask.header?.let { header ->
|
||||
// Its own line rather than beside the question, because it is a label *for* the
|
||||
@@ -77,16 +216,20 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
||||
// very little without the three it was chosen over. Marked in the same purple that says
|
||||
// "picked" while the question is still open, so it is one appearance learned once.
|
||||
val answered = ask.answers.isNotEmpty()
|
||||
if (ask.multiSelect && !answered) {
|
||||
MultipleChoice(ask.options, onAnswer)
|
||||
} else if (ask.options.all { it.description == null && it.preview == null }) {
|
||||
// What is marked: what was answered once there is an answer, and what the finger has
|
||||
// chosen until then.
|
||||
val marked = if (answered) ask.answers.toSet() else draft.picked
|
||||
// Null once the question is answered: the options stay and stop being pressable.
|
||||
val onPick: ((String) -> Unit)? =
|
||||
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
|
||||
if (ask.options.all { it.description == null && it.preview == null }) {
|
||||
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
||||
// do not need a card each.
|
||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered })
|
||||
AnswerOptions(ask.options, marked.toList(), onPick)
|
||||
} else {
|
||||
ask.options.forEach { option ->
|
||||
OptionCard(option, selected = option.label in ask.answers) {
|
||||
if (!answered) onAnswer(listOf(option.label))
|
||||
OptionCard(option, selected = option.label in marked) {
|
||||
onPick?.invoke(option.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,35 +245,24 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) ->
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (!answered) OtherAnswer(onAnswer)
|
||||
if (!answered) {
|
||||
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that can be chosen together, with one button to send them.
|
||||
* [label] added to, or taken out of, what [draft] has picked.
|
||||
*
|
||||
* The answer goes back as the list it is. What a provider makes of several answers is decided where
|
||||
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there
|
||||
* -- and nothing on this side has to know that.
|
||||
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
|
||||
* words away -- see [Draft].
|
||||
*/
|
||||
@Composable
|
||||
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
|
||||
var chosen by remember { mutableStateOf(setOf<String>()) }
|
||||
options.forEach { option ->
|
||||
OptionCard(option, selected = option.label in chosen) {
|
||||
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
|
||||
}
|
||||
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
|
||||
when {
|
||||
!multiSelect -> Draft(picked = setOf(label))
|
||||
label in draft.picked -> Draft(picked = draft.picked - label)
|
||||
else -> Draft(picked = draft.picked + label)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
// In the order they were offered rather than the order they were tapped: the reader is
|
||||
// answering a list, and it should read back as that list.
|
||||
onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) },
|
||||
enabled = chosen.isNotEmpty(),
|
||||
) {
|
||||
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One option: what it is called, what it means, and what it would produce.
|
||||
@@ -208,20 +340,17 @@ private fun Preview(preview: String) {
|
||||
* cannot tell that it was ever open.
|
||||
*/
|
||||
@Composable
|
||||
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Other") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
||||
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
||||
// what sends it. A second send button beside the field made the shorter half of the card look
|
||||
// like the one that finishes it.
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onText,
|
||||
label = { Text("Other") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,10 +364,10 @@ private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
|
||||
@Composable
|
||||
fun AnswerOptions(
|
||||
options: List<QuestionOption>,
|
||||
/** What was chosen, marked rather than restated; empty while the question is open. */
|
||||
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
|
||||
answers: List<String> = emptyList(),
|
||||
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
||||
onAnswer: ((List<String>) -> Unit)?,
|
||||
onPick: ((String) -> Unit)?,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -248,11 +377,11 @@ fun AnswerOptions(
|
||||
options.forEach { option ->
|
||||
val taken = option.label in answers
|
||||
OutlinedButton(
|
||||
onClick = { onAnswer?.invoke(listOf(option.label)) },
|
||||
onClick = { onPick?.invoke(option.label) },
|
||||
// Disabled rather than removed, so an answered question still shows what it
|
||||
// offered. Material dims a disabled button's own border and label, which would
|
||||
// take the mark with it -- both are stated here instead.
|
||||
enabled = onAnswer != null,
|
||||
enabled = onPick != null,
|
||||
border =
|
||||
BorderStroke(
|
||||
if (taken) 2.dp else 1.dp,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// The composer's row of settings and pickers, and the menus they open. One file because the
|
||||
// outline and the corner are one appearance: a control shaped like this opens a surface shaped
|
||||
// like this, and a reader learns the pair once.
|
||||
|
||||
/**
|
||||
* A bordered pill: a control that can be seen without being pressed.
|
||||
*
|
||||
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
|
||||
* all until they are touched. Three bare words sitting under the message field read as a caption
|
||||
* about the field rather than as three things to press, and the only way to find out otherwise was
|
||||
* to press one. The outline says "control" without the weight of a filled button, which is reserved
|
||||
* here for the two that act on the session (send, and start/stop).
|
||||
*/
|
||||
@Composable
|
||||
fun BubbleButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = BubbleShape,
|
||||
// A text button's padding rather than a filled button's 24dp: these sit three across
|
||||
// under the message field, and the wider padding is what decides whether the row fits.
|
||||
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||
modifier = modifier,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Fully round ends, so the control reads as a bubble rather than as a box. */
|
||||
val BubbleShape: Shape = RoundedCornerShape(percent = 50)
|
||||
|
||||
/**
|
||||
* The corner on a menu one of these opens.
|
||||
*
|
||||
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
|
||||
* ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge
|
||||
* beside a one-line option, which is the shortest menu here.
|
||||
*/
|
||||
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
|
||||
@@ -11,14 +11,25 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Which way a [Chevron] points. */
|
||||
enum class Pointing {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/**
|
||||
* A chevron, pointing up or down.
|
||||
* A chevron, pointing whichever of the four ways is asked for.
|
||||
*
|
||||
* 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 an empty box instead is never the one who wrote it.
|
||||
*
|
||||
* One composable for both directions rather than two that differ by a minus sign -- the pair would
|
||||
* drift, and the drift would be a bug in exactly one direction.
|
||||
* One composable for all four directions rather than one per axis that differ by which coordinate
|
||||
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
|
||||
* direction. The shape is written once in its own coordinates, where x runs across the opening and
|
||||
* y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto
|
||||
* the box.
|
||||
*
|
||||
* It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole
|
||||
* of what assistive technology has to go on, and it is also the answer to "what was that arrow for"
|
||||
@@ -26,28 +37,36 @@ import androidx.compose.ui.unit.dp
|
||||
*/
|
||||
@Composable
|
||||
fun Chevron(
|
||||
pointingUp: Boolean,
|
||||
pointing: Pointing,
|
||||
modifier: Modifier = Modifier,
|
||||
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Canvas(modifier.width(20.dp).height(10.dp)) {
|
||||
val sideways = pointing == Pointing.Left || pointing == Pointing.Right
|
||||
Canvas(
|
||||
modifier
|
||||
.width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN)
|
||||
.height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH)
|
||||
) {
|
||||
val inset = 2.dp.toPx()
|
||||
val point = if (pointingUp) inset else size.height - inset
|
||||
val ends = if (pointingUp) size.height - inset else inset
|
||||
val wide = size.width - inset
|
||||
val tall = size.height - inset
|
||||
fun at(across: Float, along: Float) =
|
||||
when (pointing) {
|
||||
Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along))
|
||||
Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along))
|
||||
Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across))
|
||||
Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across))
|
||||
}
|
||||
val stroke = 2.dp.toPx()
|
||||
drawLine(
|
||||
colour,
|
||||
Offset(inset, ends),
|
||||
Offset(size.width / 2, point),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(
|
||||
colour,
|
||||
Offset(size.width / 2, point),
|
||||
Offset(size.width - inset, ends),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round)
|
||||
drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction
|
||||
|
||||
/** How far the chevron opens, across the direction it points. */
|
||||
private val CHEVRON_SPAN = 20.dp
|
||||
|
||||
/** How far it reaches in the direction it points. */
|
||||
private val CHEVRON_DEPTH = 10.dp
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* A span of milliseconds, written the way somebody reads it.
|
||||
*
|
||||
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
|
||||
* halves, because a short span and a long one are read for different things. Under a minute the
|
||||
* question is "roughly how long", so only the largest unit is shown and a fraction of it carries
|
||||
* the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit
|
||||
* that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather
|
||||
* than written as zero, since the labels say which is which and `5d 0h 4m` is only longer.
|
||||
*
|
||||
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
|
||||
* milliseconds, and carrying them would make the common case the widest one.
|
||||
*/
|
||||
fun formatMillis(ms: Long): String {
|
||||
if (ms < 0) return "-" + formatMillis(-ms)
|
||||
if (ms < 1000) return "${ms}ms"
|
||||
if (ms < 60_000) {
|
||||
val tenths = (ms + 50) / 100
|
||||
val whole = tenths / 10
|
||||
val rest = tenths % 10
|
||||
return if (rest == 0L) "${whole}s" else "$whole.${rest}s"
|
||||
}
|
||||
val seconds = ms / 1000
|
||||
val parts =
|
||||
listOf(
|
||||
"d" to seconds / 86_400,
|
||||
"h" to seconds / 3600 % 24,
|
||||
"m" to seconds / 60 % 60,
|
||||
"s" to seconds % 60,
|
||||
)
|
||||
return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" }
|
||||
}
|
||||
|
||||
/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */
|
||||
fun formatMillisText(text: String): String =
|
||||
text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text
|
||||
@@ -224,7 +224,7 @@ private class LiveParse(
|
||||
val parse = parseMarkdown(tailText)
|
||||
val all = pieces(parse)
|
||||
val open = (parse as? State.Success)?.let { openPiece(it, all) }
|
||||
if (open == null || parse !is State.Success) {
|
||||
if (open == null) {
|
||||
return LiveParse(
|
||||
next,
|
||||
frozen,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
@@ -180,13 +183,52 @@ fun GlyphButton(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colour: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
MarkButton(label, onClick, modifier, enabled) {
|
||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same square, around a mark that is not a glyph.
|
||||
*
|
||||
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
|
||||
* size, spacing and touch target every other icon button on this app's headers already is -- so
|
||||
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
|
||||
* beside it. The caller still owes it a [label]: nothing here draws a word.
|
||||
*/
|
||||
@Composable
|
||||
fun MarkButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
mark: @Composable () -> Unit,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||
) {
|
||||
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
||||
mark()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The square a glyph button occupies, with a spinner in it instead of a mark.
|
||||
*
|
||||
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
|
||||
* swapping one for the other leaves everything in the row exactly where it was -- a control that
|
||||
* changed the width of its header while it worked would move its neighbours at the moment somebody
|
||||
* was pressing them.
|
||||
*/
|
||||
@Composable
|
||||
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -97,11 +98,18 @@ private fun PendingThumbnail(
|
||||
// The two are told apart for the same reason the transcript's images are: one of them
|
||||
// is worth waiting for and the other never resolves.
|
||||
null ->
|
||||
Text(
|
||||
if (failed) "!" else "…",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (failed) {
|
||||
Text(
|
||||
"!",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// A spinner, as the transcript's images have: one appearance for "a picture
|
||||
// is on its way", learned once. An ellipsis had to be read as a spinner that
|
||||
// was not moving.
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
else ->
|
||||
Image(
|
||||
bitmap = image,
|
||||
|
||||
@@ -7,13 +7,23 @@ import java.time.OffsetDateTime
|
||||
// arithmetic is the same in both and only the sentence around it differs, so everything here
|
||||
// returns the span or the state on its own and leaves the wording to the caller.
|
||||
|
||||
/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */
|
||||
fun formatSpan(until: Duration): String =
|
||||
when {
|
||||
until.toHours() >= 24 -> "${until.toDays()}d ${until.toHours() % 24}h"
|
||||
until.toHours() > 0 -> "${until.toHours()}h ${until.toMinutes() % 60}m"
|
||||
else -> "${until.toMinutes()}m"
|
||||
/**
|
||||
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
|
||||
*
|
||||
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
|
||||
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
|
||||
* figure on a minute it has already spent -- so the reader watching the number decide whether to
|
||||
* start something was consistently told less headroom than they had. One rule, so the session bar
|
||||
* and the usage dialog cannot round a shared measurement two different ways.
|
||||
*/
|
||||
fun formatSpan(until: Duration): String {
|
||||
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
|
||||
return when {
|
||||
up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h"
|
||||
up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m"
|
||||
else -> "${up.toMinutes()}m"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What is known about when a usage window ends.
|
||||
|
||||
@@ -9,6 +9,8 @@ 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.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,6 +22,7 @@ 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.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -94,14 +97,19 @@ fun SessionImage(
|
||||
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
||||
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
when (val image = bitmap) {
|
||||
// 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. So one gets a
|
||||
// spinner in the space the picture is about to fill, and the other gets words.
|
||||
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,
|
||||
)
|
||||
if (failed) {
|
||||
Text(
|
||||
"[image $ref unavailable]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LoadingImage(height)
|
||||
}
|
||||
else ->
|
||||
Image(
|
||||
bitmap = image,
|
||||
@@ -153,17 +161,51 @@ fun SessionImageViewer(
|
||||
// coming. Stated in white because this box paints its own black behind them and a
|
||||
// theme colour would be picked against a surface that is not there.
|
||||
null ->
|
||||
Text(
|
||||
if (failed) "Image $ref is unavailable" else "Loading image…",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (failed) {
|
||||
Text(
|
||||
"Image $ref is unavailable",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
// The whole dialog is the area this picture is about to fill, so the
|
||||
// spinner sits in the middle of it. White for the same reason the words
|
||||
// beside it are: this box paints its own black, and a theme colour would
|
||||
// be chosen against a surface that is not there.
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
else -> ZoomableImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The room a picture is about to take, with a spinner in the middle of it.
|
||||
*
|
||||
* A square of the row's own height rather than the full width of the transcript: the height is what
|
||||
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
|
||||
* placeholder would promise a picture wider than most of them turn out to be. Square is the closest
|
||||
* thing to "the size of it" that can be drawn before knowing.
|
||||
*
|
||||
* Tinted, so the reader can see that something is being kept for a picture. That is also what
|
||||
* distinguishes it from the failure beside it, which is words on the ordinary surface.
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingImage(height: Dp) {
|
||||
Box(
|
||||
Modifier.size(height)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small enough to sit inside the thumbnail's square without filling it. */
|
||||
private val LOADING_SPINNER = 24.dp
|
||||
|
||||
/**
|
||||
* Four lines of the body style the transcript is set in.
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.selection.rememberSelectionState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -71,6 +72,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
@@ -267,13 +270,17 @@ fun SessionScreen(
|
||||
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
|
||||
// message. See `Drafts.kt` for why this one piece of state is the device's rather than the
|
||||
// server's.
|
||||
var input by remember(summary.id) { mutableStateOf(loadDraft(context, summary.id)) }
|
||||
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) }
|
||||
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
|
||||
// makes the session re-read the whole conversation, which is worth asking about first.
|
||||
var pendingModel by remember { mutableStateOf<String?>(null) }
|
||||
// What was last taken from the command suggestions, so the list closes behind it; see
|
||||
// [CommandSuggestions] at its call site.
|
||||
var picked by remember { mutableStateOf<String?>(null) }
|
||||
// The transcript's selection, held here rather than inside [TranscriptList] because the rows
|
||||
// have to ask whether anything is selected before they treat a tap as their own -- see
|
||||
// [expanding].
|
||||
val selection = rememberSelectionState()
|
||||
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.
|
||||
@@ -485,6 +492,30 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A press on the transcript that would open or close something, and the one thing every such
|
||||
* press has to check first.
|
||||
*
|
||||
* The transcript is one [SelectionContainer], so a reader who has selected some text puts that
|
||||
* selection away by tapping -- and the tap that does it lands on whatever card the text is
|
||||
* drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection
|
||||
* and the tool call under their finger collapses, which is a second thing happening for a
|
||||
* gesture that meant one. So a press with a selection outstanding spends itself clearing it and
|
||||
* does nothing else, and the press after that -- with nothing selected -- opens or closes as
|
||||
* usual.
|
||||
*
|
||||
* Every open and close on this screen goes through here rather than each writing the check,
|
||||
* since which card the finger lands on is not something the reader chose and the rule cannot
|
||||
* hold for only some of them.
|
||||
*/
|
||||
fun expanding(toggle: () -> Unit) {
|
||||
if (selection.selectedTexts.isNotEmpty()) {
|
||||
selection.clear()
|
||||
return
|
||||
}
|
||||
toggle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a row's height while the end the reader touched stays where it is.
|
||||
*
|
||||
@@ -505,7 +536,7 @@ fun SessionScreen(
|
||||
* comes from the row's own detector ([LastTouch]), written by the gesture that is about to run
|
||||
* [toggle].
|
||||
*/
|
||||
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
|
||||
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding {
|
||||
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
|
||||
toggle()
|
||||
}
|
||||
@@ -1027,7 +1058,7 @@ fun SessionScreen(
|
||||
}
|
||||
|
||||
/** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */
|
||||
fun toggleMemory(text: String) {
|
||||
fun toggleMemory(text: String) = expanding {
|
||||
openMemories = if (text in openMemories) openMemories - text else openMemories + text
|
||||
}
|
||||
|
||||
@@ -1043,7 +1074,7 @@ fun SessionScreen(
|
||||
* the reader tapped keeps its place because the list keeps it, not because a measurement
|
||||
* corrected it afterwards.
|
||||
*/
|
||||
fun togglePeer(seq: Long) {
|
||||
fun togglePeer(seq: Long) = expanding {
|
||||
expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq
|
||||
}
|
||||
|
||||
@@ -1063,8 +1094,21 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends every answer a question card handed over, and says when the last of them has settled.
|
||||
*
|
||||
* All of them in one go because a card asks its questions together and the tool is waiting on
|
||||
* all of them; the completion is what turns the card's spinner back into a button, whether the
|
||||
* server took them or refused.
|
||||
*/
|
||||
fun answerAll(answers: List<QuestionAnswer>, onSettled: () -> Unit) {
|
||||
act(onDone = onSettled) {
|
||||
answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) }
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
val text = input.trim()
|
||||
val text = input.text.trim()
|
||||
val attachments = pendingAttachments
|
||||
if (text.isEmpty() && attachments.isEmpty()) return
|
||||
// A command is not a message: it is an instruction to the session about itself, and one
|
||||
@@ -1072,7 +1116,7 @@ fun SessionScreen(
|
||||
// turn ends and says so, which is where its waiting bubble comes from -- so nothing is
|
||||
// held here, and there is no local guess to correct when the answer arrives.
|
||||
if (text.startsWith("/") && attachments.isEmpty()) {
|
||||
input = ""
|
||||
input = atEnd("")
|
||||
saveDraft(context, summary.id, "")
|
||||
// The one command with a visible effect outside the transcript, applied when the
|
||||
// server has accepted it rather than when it was typed: the name is this app's own
|
||||
@@ -1087,7 +1131,7 @@ fun SessionScreen(
|
||||
}
|
||||
return
|
||||
}
|
||||
input = ""
|
||||
input = atEnd("")
|
||||
saveDraft(context, summary.id, "")
|
||||
pendingAttachments = emptyList()
|
||||
// Nothing is added here. The server says what is waiting -- it emits `messageQueued`
|
||||
@@ -1132,14 +1176,15 @@ fun SessionScreen(
|
||||
onShareTaken()
|
||||
incoming.uris.forEach(::attach)
|
||||
incoming.text?.let { shared ->
|
||||
input = if (input.isBlank()) shared else input + "\n" + shared
|
||||
saveDraft(context, summary.id, input)
|
||||
input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared)
|
||||
saveDraft(context, summary.id, input.text)
|
||||
}
|
||||
}
|
||||
|
||||
// One poll for this machine's limits, read by the two things that show them: the bar under
|
||||
// the header, and the colour of the button that opens the dialog.
|
||||
val usage = rememberSessionUsage(settings, summary.setup)
|
||||
// One poll for the machines' limits, read by everything on this screen that reports them:
|
||||
// the bar under the header, the colour of the button that opens the dialog, and the dialog.
|
||||
val usageFeed = rememberUsageFeed(settings)
|
||||
val usage = usageFeed.forSetup(summary.setup)
|
||||
RecordFrames()
|
||||
var usageOpen by remember { mutableStateOf(false) }
|
||||
var settingsOpen by remember { mutableStateOf(false) }
|
||||
@@ -1341,6 +1386,7 @@ fun SessionScreen(
|
||||
units = units,
|
||||
state = listState,
|
||||
moreHistory = moreHistory,
|
||||
selection = selection,
|
||||
modifier =
|
||||
Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() },
|
||||
below = {
|
||||
@@ -1476,16 +1522,7 @@ fun SessionScreen(
|
||||
else expandedTools + id
|
||||
}
|
||||
},
|
||||
onAnswer = { questionId, answers ->
|
||||
act {
|
||||
answerQuestion(
|
||||
settings,
|
||||
summary.id,
|
||||
questionId,
|
||||
answers,
|
||||
)
|
||||
}
|
||||
},
|
||||
onAnswer = ::answerAll,
|
||||
image = { ref ->
|
||||
SessionImage(
|
||||
settings,
|
||||
@@ -1535,16 +1572,7 @@ fun SessionScreen(
|
||||
else expandedTools + item.id
|
||||
}
|
||||
},
|
||||
onAnswer = { questionId, answers ->
|
||||
act {
|
||||
answerQuestion(
|
||||
settings,
|
||||
summary.id,
|
||||
questionId,
|
||||
answers,
|
||||
)
|
||||
}
|
||||
},
|
||||
onAnswer = ::answerAll,
|
||||
image = { ref ->
|
||||
SessionImage(
|
||||
settings,
|
||||
@@ -1555,16 +1583,7 @@ fun SessionScreen(
|
||||
},
|
||||
)
|
||||
is TranscriptItem.QuestionCard ->
|
||||
QuestionRow(item) { answers ->
|
||||
act {
|
||||
answerQuestion(
|
||||
settings,
|
||||
summary.id,
|
||||
item.id,
|
||||
answers,
|
||||
)
|
||||
}
|
||||
}
|
||||
QuestionRow(item, ::answerAll)
|
||||
is TranscriptItem.ErrorMsg ->
|
||||
Text(
|
||||
item.message,
|
||||
@@ -1658,7 +1677,7 @@ fun SessionScreen(
|
||||
.semantics { contentDescription = "Jump to latest" },
|
||||
) {
|
||||
Chevron(
|
||||
pointingUp = false,
|
||||
Pointing.Down,
|
||||
colour = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
)
|
||||
@@ -1720,9 +1739,13 @@ fun SessionScreen(
|
||||
// has nothing left to offer, in front of the box they are about to send from.
|
||||
// Held by what was picked rather than by a flag, so typing anything else brings
|
||||
// the list back without needing a second thing to reset.
|
||||
commands = if (input == picked) emptyList() else suggestedCommands(input),
|
||||
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text),
|
||||
onPick = { command ->
|
||||
input = command.typed()
|
||||
// At the end of what was inserted, which is where the reader carries on
|
||||
// typing: a command with an argument is put in the box half-written, and a
|
||||
// cursor left at the front makes the next keystroke the first character of
|
||||
// "/rename" rather than of the name.
|
||||
input = atEnd(command.typed())
|
||||
picked = command.typed()
|
||||
},
|
||||
)
|
||||
@@ -1747,7 +1770,7 @@ fun SessionScreen(
|
||||
value = input,
|
||||
onValueChange = {
|
||||
input = it
|
||||
saveDraft(context, summary.id, it)
|
||||
saveDraft(context, summary.id, it.text)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// No longer "(+image)": the images are on screen above this, and a placeholder
|
||||
@@ -1764,13 +1787,14 @@ fun SessionScreen(
|
||||
var attaching by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
// Just "+". The count it used to carry was standing in for showing them.
|
||||
TextButton(onClick = { attaching = true }) { Text("+") }
|
||||
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
||||
DropdownMenu(
|
||||
expanded = attaching,
|
||||
onDismissRequest = { attaching = false },
|
||||
// See PickerButton: without this the menu opens a status bar's
|
||||
// height away from the button in an edge-to-edge activity.
|
||||
properties = PopupProperties(clippingEnabled = false),
|
||||
shape = BubbleMenuShape,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Photo") },
|
||||
@@ -1883,7 +1907,7 @@ fun SessionScreen(
|
||||
// not hidden, for the reason the button beside it is always here.
|
||||
Button(
|
||||
onClick = { send() },
|
||||
enabled = input.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||
colors = actionButtonColors(if (running) queueColor else sendColor),
|
||||
) {
|
||||
Glyph(
|
||||
@@ -1902,7 +1926,7 @@ fun SessionScreen(
|
||||
// open is the screen's business rather than any row's. See [SessionImageViewer].
|
||||
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
|
||||
if (usageOpen) {
|
||||
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
|
||||
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
||||
}
|
||||
if (settingsOpen) {
|
||||
SessionSettingsDialog(
|
||||
@@ -2097,10 +2121,18 @@ private data class QueuedMessage(
|
||||
* always going to re-read the conversation -- the switch adds nothing to that bill. And a session
|
||||
* reporting zero context is holding nothing, which is what `/clear` leaves behind.
|
||||
*
|
||||
* Where the figure is *unknown* rather than zero the fallback is what it always was: whether
|
||||
* anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the
|
||||
* Where the figure is *unknown* rather than zero the fallback is whether anything has been said
|
||||
* **since the last clear**. Unknown is not nothing, and treating it as nothing would drop the
|
||||
* warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet
|
||||
* and the conversation may be enormous.
|
||||
* and the conversation may be enormous. But a clear is the one case that makes the whole loaded
|
||||
* transcript stop counting: it leaves the conversation on screen and takes it out of the session's
|
||||
* context, and the server reports the context as unmeasured afterwards rather than as zero, since
|
||||
* nobody has counted what is left. So the reading that used the whole list warned about dropping a
|
||||
* cache that the clear had already dropped -- on the screen where a reader has just deliberately
|
||||
* emptied the thing being warned about.
|
||||
*
|
||||
* With no clear anywhere in what is loaded this is the old reading exactly, which is the
|
||||
* conservative answer for a clear that happened further back than the loaded window.
|
||||
*/
|
||||
private fun worthWarningAbout(
|
||||
status: String,
|
||||
@@ -2110,7 +2142,7 @@ private fun worthWarningAbout(
|
||||
when {
|
||||
status == "exited" -> false
|
||||
contextTokens != null -> contextTokens > 0
|
||||
else -> items.isNotEmpty()
|
||||
else -> items.asReversed().takeWhile { it !is TranscriptItem.ClearedNote }.isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2272,17 +2304,29 @@ private fun SessionStatusRow(
|
||||
@Composable
|
||||
private fun QuestionRow(
|
||||
question: TranscriptItem.QuestionCard,
|
||||
onAnswer: (List<String>) -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
// The same body the questions on a tool call get: one question is the same
|
||||
// thing whether or not something else asked it.
|
||||
AskedQuestion(question, onAnswer)
|
||||
// The same body the questions on a tool call get, down to the submit button: one
|
||||
// question is the same thing whether or not something asked it, and two renderings of
|
||||
// it would be two places for an answer to go missing.
|
||||
AskUserQuestionBody(listOf(question), onAnswer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [text] in the message box, with the cursor after it.
|
||||
*
|
||||
* Everything that puts words in the box without the reader typing them goes through here: a
|
||||
* restored draft, a share arriving from another app, a slash command taken from the suggestions.
|
||||
* All three leave the reader mid-sentence, and all three used to leave the cursor at whatever
|
||||
* offset it happened to hold -- which for a box that has never been focused is the very start, so
|
||||
* picking `/rename` and typing put the name in front of the command.
|
||||
*/
|
||||
private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length))
|
||||
|
||||
/**
|
||||
* How long after a menu closes a press on its own button still counts as the press that closed it.
|
||||
*
|
||||
@@ -2314,7 +2358,7 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
||||
// as a new one.
|
||||
var closedAt by remember { mutableLongStateOf(0L) }
|
||||
Box {
|
||||
TextButton(
|
||||
BubbleButton(
|
||||
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
||||
) {
|
||||
// One line, truncated rather than wrapped: this sits in a row
|
||||
@@ -2353,6 +2397,7 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
||||
closedAt = SystemClock.uptimeMillis()
|
||||
},
|
||||
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
||||
shape = BubbleMenuShape,
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -55,28 +56,63 @@ sealed class SessionUsage {
|
||||
private const val REFRESH_MS = 60_000L
|
||||
|
||||
/**
|
||||
* One machine's rate limits, polled.
|
||||
* One poll of every machine's limits, and the handle to ask again.
|
||||
*
|
||||
* Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer
|
||||
* -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would
|
||||
* cost two round trips to say one thing, and the two copies would disagree for up to a minute at a
|
||||
* time, which is the interface contradicting itself about a number somebody is deciding on.
|
||||
* A screen shows this answer in more than one place -- the bar under the session header, the colour
|
||||
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
|
||||
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
|
||||
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
|
||||
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
|
||||
* [refresh] moves both.
|
||||
*/
|
||||
class UsageFeed(
|
||||
val snapshots: LoadState<List<UsageSnapshot>>,
|
||||
/**
|
||||
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
|
||||
*/
|
||||
val refreshing: Boolean,
|
||||
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
|
||||
val refresh: () -> Unit,
|
||||
) {
|
||||
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */
|
||||
fun forSetup(setup: String): SessionUsage =
|
||||
when (val state = snapshots) {
|
||||
is LoadState.Loading -> SessionUsage.Waiting
|
||||
is LoadState.Error -> SessionUsage.Unavailable(state.message)
|
||||
is LoadState.Loaded -> usageFor(state.value, setup)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one poll of the machines' rate limits, polled and refreshable.
|
||||
*
|
||||
* Hoisted out of [SessionUsageBar] because everything on a session's screen that reports on usage
|
||||
* has to be reporting the same measurement; see [UsageFeed].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage {
|
||||
var usage by remember(setup) { mutableStateOf<SessionUsage>(SessionUsage.Waiting) }
|
||||
LaunchedEffect(setup) {
|
||||
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
|
||||
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||
var refreshing by remember { mutableStateOf(true) }
|
||||
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
|
||||
// also resets the countdown to the next one rather than leaving one due immediately after.
|
||||
var asked by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(asked) {
|
||||
while (true) {
|
||||
usage =
|
||||
refreshing = true
|
||||
// Replaces the answer only once the next one is in hand: dropping back to Loading
|
||||
// would blank a bar somebody is reading for the length of a round trip, and what was
|
||||
// on screen is still the last thing the machine actually said.
|
||||
snapshots =
|
||||
try {
|
||||
usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup)
|
||||
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
|
||||
} catch (e: ApiException) {
|
||||
SessionUsage.Unavailable(e.message ?: "couldn't reach the backend")
|
||||
LoadState.failed(e)
|
||||
}
|
||||
refreshing = false
|
||||
delay(REFRESH_MS)
|
||||
}
|
||||
}
|
||||
return usage
|
||||
return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dragging the screen to the right to step back to the one behind it.
|
||||
*
|
||||
* The platform's own back gesture is a swipe from the very edge, and only from there; on a phone
|
||||
* held in one hand the way back from a session is either that narrow strip or the arrow at the top
|
||||
* left, which is the far corner from the thumb. This is the same movement from anywhere on the
|
||||
* screen.
|
||||
*
|
||||
* **It loses every argument.** The gesture is a plain horizontal [draggable] on the outside of the
|
||||
* screen, so anything inside that wants horizontal drags has already taken them by the time this
|
||||
* would see them: pointer events reach the innermost node first, and a drag a child has consumed
|
||||
* never crosses this modifier's touch slop. That is what keeps a wide code fence, a table scrolled
|
||||
* sideways or a text selection working -- they are the components the reader meant, and this is
|
||||
* only what is left over. Vertical drags are not its orientation, so the transcript scrolls
|
||||
* untouched.
|
||||
*
|
||||
* The screen follows the finger rather than jumping at the end, because a gesture with no feedback
|
||||
* cannot be aborted: the reader has to be able to see it starting and change their mind. Released
|
||||
* short of [SWIPE_BACK_TRAVEL] it slides back and nothing happens. Right rather than left, and only
|
||||
* right, since there is nothing forward of these screens to go to.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.swipeBack(onBack: () -> Unit): Modifier {
|
||||
val offset = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val travel = with(LocalDensity.current) { SWIPE_BACK_TRAVEL.toPx() }
|
||||
return draggable(
|
||||
state =
|
||||
rememberDraggableState { delta ->
|
||||
// Rightward only: a leftward drag stays at zero rather than lifting the
|
||||
// screen off its left edge, which would look like a gesture that does
|
||||
// something and does not.
|
||||
scope.launch { offset.snapTo((offset.value + delta).coerceAtLeast(0f)) }
|
||||
},
|
||||
orientation = Orientation.Horizontal,
|
||||
onDragStopped = {
|
||||
if (offset.value >= travel) {
|
||||
onBack()
|
||||
// Straight back rather than animated: the screen this was moving is being
|
||||
// replaced, and animating it home first would show the old one sliding back
|
||||
// into place after the new one had arrived.
|
||||
offset.snapTo(0f)
|
||||
} else {
|
||||
offset.animateTo(0f)
|
||||
}
|
||||
},
|
||||
)
|
||||
// Read inside the block, so following the finger is a draw-phase change and costs no
|
||||
// recomposition of the screen being dragged.
|
||||
.graphicsLayer { translationX = offset.value }
|
||||
}
|
||||
|
||||
/** How far the screen has to be pulled for letting go to mean "back" rather than "never mind". */
|
||||
private val SWIPE_BACK_TRAVEL: Dp = 96.dp
|
||||
@@ -25,7 +25,9 @@ private object Mocha {
|
||||
val Sky = Color(0xFF89DCEB)
|
||||
val Blue = Color(0xFF89B4FA)
|
||||
val Lavender = Color(0xFFB4BEFE)
|
||||
val Pink = Color(0xFFF5C2E7)
|
||||
val Text = Color(0xFFCDD6F4)
|
||||
val Subtext1 = Color(0xFFBAC2DE)
|
||||
val Subtext0 = Color(0xFFA6ADC8)
|
||||
val Overlay0 = Color(0xFF6C7086)
|
||||
val Surface2 = Color(0xFF585B70)
|
||||
@@ -216,6 +218,45 @@ fun catppuccinSyntax(): SyntaxPalette =
|
||||
mark = Mocha.Sky,
|
||||
)
|
||||
|
||||
/**
|
||||
* The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette].
|
||||
*
|
||||
* Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by
|
||||
* eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has
|
||||
* already made for every other blue on the screen.
|
||||
*
|
||||
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
|
||||
* is upstream's choice and not an omission here. A program that uses bright red to mean something
|
||||
* other than red is relying on a distinction its own terminal may not draw either.
|
||||
*
|
||||
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
|
||||
* video needs to know what it is reversing against.
|
||||
*/
|
||||
fun ansiPalette(): AnsiPalette =
|
||||
AnsiPalette(
|
||||
colours =
|
||||
listOf(
|
||||
Mocha.Surface1,
|
||||
Mocha.Red,
|
||||
Mocha.Green,
|
||||
Mocha.Yellow,
|
||||
Mocha.Blue,
|
||||
Mocha.Pink,
|
||||
Mocha.Teal,
|
||||
Mocha.Subtext1,
|
||||
Mocha.Surface2,
|
||||
Mocha.Red,
|
||||
Mocha.Green,
|
||||
Mocha.Yellow,
|
||||
Mocha.Blue,
|
||||
Mocha.Pink,
|
||||
Mocha.Teal,
|
||||
Mocha.Subtext0,
|
||||
),
|
||||
foreground = Mocha.Text,
|
||||
background = Mocha.Crust,
|
||||
)
|
||||
|
||||
/**
|
||||
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
|
||||
*/
|
||||
|
||||
@@ -30,8 +30,8 @@ data class ToolInput(
|
||||
/** 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.
|
||||
* How long the call may take, in the largest units it fits ([formatMillis]). 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. */
|
||||
@@ -83,7 +83,7 @@ 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 timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
|
||||
val rest =
|
||||
json
|
||||
.keys()
|
||||
|
||||
@@ -19,7 +19,10 @@ 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
|
||||
@@ -174,7 +177,7 @@ fun ToolGroup(
|
||||
onToggle: () -> Unit,
|
||||
isToolExpanded: (String) -> Boolean,
|
||||
onToolToggle: (String) -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
) {
|
||||
val heading = "Called ${group.calls.size} tools"
|
||||
@@ -254,7 +257,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Chevron(pointingUp = true, colour = colour)
|
||||
Chevron(Pointing.Up, colour = colour)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +309,7 @@ fun ToolCard(
|
||||
tool: TranscriptItem.ToolRun,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> 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,
|
||||
@@ -380,9 +383,17 @@ fun ToolCard(
|
||||
// face it was written for: this is column-aligned far more often than it is
|
||||
// prose -- a directory listing, a diff, a table of numbers -- and a
|
||||
// proportional font silently destroys the alignment that carried the meaning.
|
||||
//
|
||||
// Its terminal styling applied and the rest of the escapes taken out, since
|
||||
// what a shell prints is written for a terminal: colour is often the whole of
|
||||
// what a diff or a test run is saying, and the sequences that carry it are
|
||||
// unreadable drawn verbatim. Remembered against the text, so a card that is
|
||||
// open through a scroll parses once. See [ansiStyled].
|
||||
val palette = remember { ansiPalette() }
|
||||
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
|
||||
RawBlock(Modifier.padding(top = 2.dp)) {
|
||||
Text(
|
||||
tool.output,
|
||||
styled,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
@@ -398,9 +409,7 @@ fun ToolCard(
|
||||
if (tool.tool == ASK_USER_QUESTION) {
|
||||
AskUserQuestionBody(tool.asks, onAnswer)
|
||||
} else {
|
||||
tool.asks.forEach { ask ->
|
||||
PermissionAsk(ask) { answers -> onAnswer(ask.id, answers) }
|
||||
}
|
||||
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +423,17 @@ fun ToolCard(
|
||||
* 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<String>) -> Unit) {
|
||||
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 several choices and worth reviewing --
|
||||
// so the press has to be its own acknowledgement or the row sits unchanged for a round trip
|
||||
// and reads as having missed the tap. Cleared when the request settles: by then either the
|
||||
// answer is in `ask.answers` and the mark stands on a measurement, or it failed and the
|
||||
// buttons come back rather than leaving a decision marked that nothing recorded.
|
||||
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
ask.prompt.substringBefore('\n'),
|
||||
@@ -425,7 +444,18 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<Stri
|
||||
// [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, and
|
||||
// whether a tool was allowed or refused is the thing a reader comes back to this row for.
|
||||
AnswerOptions(ask.options, ask.answers, onAnswer.takeIf { ask.answers.isEmpty() })
|
||||
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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.text.selection.SelectionState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,17 +45,23 @@ import androidx.compose.ui.unit.dp
|
||||
* drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep
|
||||
* their tap handlers: selection is a long press, and the container passes an ordinary click through
|
||||
* to the card under it.
|
||||
*
|
||||
* [selection] is the container's own state, held by the caller rather than made here, because the
|
||||
* rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose
|
||||
* job is to put a selection away is not also a tap on the card under it. See the caller's
|
||||
* `expanding`.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscriptList(
|
||||
units: List<TranscriptUnit>,
|
||||
state: LazyListState,
|
||||
moreHistory: Boolean,
|
||||
selection: SelectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
below: @Composable () -> Unit,
|
||||
unit: @Composable (TranscriptUnit) -> Unit,
|
||||
) {
|
||||
SelectionContainer {
|
||||
SelectionContainer(selection) {
|
||||
LazyColumn(
|
||||
state = state,
|
||||
reverseLayout = true,
|
||||
|
||||
@@ -15,20 +15,11 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import java.time.OffsetDateTime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Window bars for the account's rate limits, with reset times.
|
||||
@@ -41,23 +32,7 @@ import kotlinx.coroutines.withContext
|
||||
* handles that itself.
|
||||
*/
|
||||
@Composable
|
||||
fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
|
||||
|
||||
fun refresh() {
|
||||
state = LoadState.Loading
|
||||
scope.launch {
|
||||
state =
|
||||
try {
|
||||
withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) }
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { refresh() }
|
||||
|
||||
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
|
||||
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
|
||||
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
|
||||
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
|
||||
@@ -84,7 +59,14 @@ fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() })
|
||||
// A spinner in the button's place while the answer is on its way, since the
|
||||
// numbers under it stay put during a refresh -- without it, pressing refresh
|
||||
// over an unchanged read-out looks like a button that does nothing.
|
||||
if (feed.refreshing) {
|
||||
GlyphSpinner("Refreshing usage")
|
||||
} else {
|
||||
GlyphButton(REFRESH_GLYPH, "Refresh usage", feed.refresh)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Scrolls rather than being trimmed: a machine can report any number of windows
|
||||
@@ -92,7 +74,7 @@ fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
|
||||
// running out of room is silent. `fill = false` so a short read-out keeps a short
|
||||
// dialog instead of stretching to the window.
|
||||
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||
UsageBody(state)
|
||||
UsageBody(feed.snapshots)
|
||||
}
|
||||
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
||||
Text("Close")
|
||||
|
||||
Reference in new issue
Block a user