diff --git a/TODO.md b/TODO.md index b93385f..b62edb4 100644 --- a/TODO.md +++ b/TODO.md @@ -3,56 +3,26 @@ Working list from Iris, 2026-09-03. Remove an entry when it lands; annotate one in place when it turns out to need a decision. -## App — composer and input - -- [ ] Make the file-add button and the ones beside it "bubble" buttons with a - visible outline. The popups they open should be round. -- [ ] Returning to the app must not open the keyboard if it was closed when - the app was left. -- [ ] Tapping a slash-command suggestion should move the cursor to the end of - the inserted suggestion. - ## App — transcript -- [ ] Tapping an expanded agent message card should close it. -- [ ] Tapping to deselect text should not toggle card expansion. +- [ ] Tapping an expanded agent message card should close it. — a tap that + clears a selection now spends itself on that and nothing else + (`SessionScreen.expanding`); still to check on the emulator whether a tap + with *no* selection reaches an opened peer card at all. - [ ] Text inside code blocks does not highlight when selected (selection - itself works — only the highlight is missing). -- [ ] Bash logs should apply colour and the other basic text escape sequences, - and filter the rest. -- [ ] An image should show a loading spinner in an area the size of the image. + itself works — only the highlight is missing). Waiting on the app-c7 + session, which is replacing the highlighter (`HIGHLIGHTER_PLAN.md`) and + owns `CodeFence.kt` until it pushes; it confirmed this item is ours. - [ ] Messages received from other agents are inconsistent — sometimes they appear, sometimes they don't. -## App — navigation - -- [ ] Swiping right should open the session list, unless the gesture belongs to - a component (e.g. scrolling left inside a long text block). - -## App — AskUserQuestion card - -- [ ] Selection should highlight instantly instead of waiting; a Submit button - at the bottom sends, and becomes a spinner while sending. -- [ ] Arrow buttons in the top right navigate between questions instead of - stacking them all in a row. -- [ ] Submit stays greyed out until every question is answered. - -## Formatting - -- [ ] Show a timeout in the largest possible unit: 480000ms = 8m. Under a - minute show only the largest unit (2.5s, 30ms); at or over a minute show - all units (5d 12h 4m). -- [ ] Use tilde notation when moving the working directory. - ## Session settings - [ ] Autocompact belongs in session settings; empty disables it, which is the - default. -- [ ] Changing the model on a stopped provider should be possible, stored, and - applied the next time it starts. + default. **Needs a decision before building** — nothing called autocompact + exists yet on either side. `PLAN.md` has it only as a planned pi-driver + feature (`set_auto_compaction`), and Claude Code runs its own. So this is + a new server feature, and the open questions are what the empty-or-not + value *is* (a token count? a percentage of the context window?) and which + drivers it applies to. -## Usage - -- [ ] Add one minute to Claude's "time left". -- [ ] The transcript header and the usage menu should share one usage value — - they have been seen disagreeing. diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 2958fb5..cb94924 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -37,12 +37,20 @@ + 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. --> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt new file mode 100644 index 0000000..a252c48 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt @@ -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, + /** 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() + 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) { + 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, at: Int, palette: AnsiPalette): Pair = + 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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 5a650c8..ad1b81c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -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 -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt index 36300d1..3e09769 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -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) + /** - * 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 = 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): List = + 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, - onAnswer: (questionId: String, answers: List) -> Unit, + onAnswer: (List, 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) -> 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) -> // 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) -> 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, onAnswer: (List) -> Unit) { - var chosen by remember { mutableStateOf(setOf()) } - 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) -> 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) -> Unit) { @Composable fun AnswerOptions( options: List, - /** 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 = emptyList(), /** Null once the question is answered -- the buttons stay, and stop being buttons. */ - onAnswer: ((List) -> 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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt new file mode 100644 index 0000000..2da613f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt @@ -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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt index 560a411..36cda0c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt new file mode 100644 index 0000000..bc18ad8 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index d9699fc..82ad370 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 92068c1..9482384 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -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) } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt index 96e833e..6665132 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt @@ -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, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt index b6252e6..1db6b3a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt index 027be8f..df21ca6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt @@ -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. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index ea2438c..617a439 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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(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(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()) } // 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, 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) -> Unit, + onAnswer: (List, 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, 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, onPick: (String closedAt = SystemClock.uptimeMillis() }, properties = PopupProperties(focusable = false, clippingEnabled = false), + shape = BubbleMenuShape, ) { options.forEach { option -> DropdownMenuItem( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index ada615f..c2b3695 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -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>, + /** + * 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.Waiting) } - LaunchedEffect(setup) { +fun rememberUsageFeed(settings: ServerSettings): UsageFeed { + var snapshots by remember { mutableStateOf>>(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++ } } } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt new file mode 100644 index 0000000..7697715 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index f8c9ce4..edee135 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -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. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index ff4350c..2f305e9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -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() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 11fb2b4..3ef2ddc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -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) -> Unit, + onAnswer: (List, 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) -> Unit, + onAnswer: (List, 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) -> Unit) { +private fun PermissionAsk( + ask: TranscriptItem.QuestionCard, + onAnswer: (List, 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(null) } Spacer(Modifier.height(8.dp)) Text( ask.prompt.substringBefore('\n'), @@ -425,7 +444,18 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List + pressed = label + onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null } + }, + ) } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt index 8618784..35ec86a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt @@ -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, state: LazyListState, moreHistory: Boolean, + selection: SelectionState, modifier: Modifier = Modifier, below: @Composable () -> Unit, unit: @Composable (TranscriptUnit) -> Unit, ) { - SelectionContainer { + SelectionContainer(selection) { LazyColumn( state = state, reverseLayout = true, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 39ecf07..b2bf44d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -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.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") diff --git a/server/src/routes.rs b/server/src/routes.rs index 114a5b5..9ba6659 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1177,8 +1177,18 @@ async fn set_cwd( setup.name ))); } + // Stored in the short form, so the one path that is kept is the one + // the phone will draw -- rather than storing `/home/bob/…` and + // abbreviating it again at each place it is shown, which is two + // representations of one directory and a second rule to keep in step. + // Only where the setup runs here; see `setups::shorten_home`. + let stored = if setup.ssh.is_none() { + crate::setups::shorten_home(&cwd) + } else { + cwd.clone() + }; manager - .set_session_cwd(&id, PathBuf::from(&cwd)) + .set_session_cwd(&id, PathBuf::from(&stored)) .map_err(bad_request)?; Ok(StatusCode::NO_CONTENT) } diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 122176a..103c92e 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -441,7 +441,14 @@ pub async fn directory_exists(transport: &Transport, path: &str) -> bool { if path.is_empty() { return false; } - let launch = Launch::new("test", vec!["-d".to_string(), path.to_string()], None); + // Asked by *entering* it rather than by `test -d `, because the + // question this is standing in for is "can a session start here" and + // because a path is only expanded where it is a working directory -- + // `~/repos/ai-app` as an argument stays four literal characters on + // both transports (`ssh::quote_path`, `ssh::expand_home`), so the old + // form answered "no such directory" about every home-relative path + // somebody typed. + let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path))); transport.capture(&launch).await.is_ok() } diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index a3db9f2..f21241e 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1217,9 +1217,16 @@ impl SessionManager { // change, and as an error if it cannot. The config above is a // different question -- what to launch this session with next // time -- and it is answered by the request. - session.ask("change how much it asks", |driver| { - driver.set_permission_mode(mode) - }); + announce_or_ask( + session, + &self.data_dir.join(id), + Event::Settings { + model: None, + permission_mode: Some(mode.to_string()), + }, + "change how much it asks", + |driver| driver.set_permission_mode(mode), + ); } Ok(()) } @@ -1323,7 +1330,16 @@ impl SessionManager { if let Some(session) = inner.live.get(id) { // See `set_session_permission_mode`: the driver reports what // it is set to, this only asks. - session.ask("change model", |driver| driver.set_model(model)); + announce_or_ask( + session, + &self.data_dir.join(id), + Event::Settings { + model: Some(model.to_string()), + permission_mode: None, + }, + "change model", + |driver| driver.set_model(model), + ); } Ok(()) } @@ -1685,6 +1701,37 @@ impl SessionManager { /// to the one process, and every line it wrote was then translated once per /// reader -- three presses put three interleaved copies of one reply on /// screen. +/// A setting change: asked of the driver, or announced as the session's own +/// where there is no process for a driver to speak for. +/// +/// The pair that [`LiveSession::ask`] cannot serve. Everything else it +/// covers genuinely needs a process -- a message sent to a session that is +/// not running has nowhere to go -- but a setting is held in the config as +/// well, and a session with nothing running *is* what the config says: the +/// value is applied the moment it next starts. So `ask`'s "this session has +/// no process running, so it can't change model" was true of the driver and +/// false of the session, and it left the phone showing the old model over a +/// config that had already taken the new one, with no way to change it +/// short of starting the session first. +/// +/// `Exited` and nothing else, for the reason [`start_if_exited`] gives: +/// `Unknown` means nobody could find out, and a session whose process may +/// well be reading its fifo is one to ask rather than to answer for. +fn announce_or_ask( + session: &LiveSession, + session_dir: &Path, + settled: Event, + what: &str, + request: impl FnOnce(&dyn Driver), +) { + let status = corrected(*session.shared.status.lock().unwrap(), session_dir); + if status == SessionStatus::Exited { + let _ = session.sink.send(settled); + } else { + session.ask(what, request); + } +} + fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus { if status == SessionStatus::Exited && adoptable(session_dir) { SessionStatus::Unknown @@ -3613,6 +3660,74 @@ mod tests { std::fs::write(path, rewritten).expect("write transcript"); } + /// A setting changed on a session with nothing running is recorded as + /// the session's own, rather than refused because there is no driver. + /// + /// The config already took it -- that is what a session starts with next + /// time -- so the refusal was about the driver while reading as though + /// it were about the session, and the phone went on showing the old + /// model over a stored new one. Asked of a session told it has exited, + /// since the rule is about the status rather than about which driver it + /// is; the same is true of the permission mode, which is why they go + /// through one function. + #[tokio::test] + async fn a_stopped_session_takes_a_setting_for_the_next_time_it_starts() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.ron"); + let data_dir = dir.path().join("sessions"); + seed_echo_only(&config_path); + let manager = SessionManager::new( + config_path.clone(), + data_dir.clone(), + data_dir.join("models"), + ) + .expect("manager"); + let info = manager.spawn_session(echo_spec()).expect("spawn"); + let session = manager.session(&info.id).expect("live session"); + let mut rx = session.subscribe(); + + let _ = session.sink.send(Event::Status { + state: SessionStatus::Exited, + }); + collect_until(&mut rx, |event| { + matches!( + event, + Event::Status { + state: SessionStatus::Exited + } + ) + }) + .await; + + manager + .set_session_model(&info.id, "haiku") + .expect("store the model"); + collect_until( + &mut rx, + |event| matches!(event, Event::Settings { model: Some(model), .. } if model == "haiku"), + ) + .await; + assert_eq!( + manager.sessions()[0].model.as_deref(), + Some("haiku"), + "stored, so the next start uses it" + ); + + manager + .set_session_permission_mode(&info.id, "plan") + .expect("store the mode"); + collect_until(&mut rx, |event| { + matches!( + event, + Event::Settings { + permission_mode: Some(mode), + .. + } if mode == "plan" + ) + }) + .await; + } + /// Stopping and starting a session is about its *process*, and the two /// refusals are the whole of what keeps starting one from becoming a /// second one on the same conversation. diff --git a/server/src/setups.rs b/server/src/setups.rs index b3d3a82..52d2088 100644 --- a/server/src/setups.rs +++ b/server/src/setups.rs @@ -160,6 +160,32 @@ pub fn tidy(value: &str) -> Option { }) } +/// The inverse of [`tidy`]'s expansion: an absolute path under this +/// machine's home, written back as `~/…`. +/// +/// So that a working directory reads on a phone the way it is written by +/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and +/// almost all of it is the part nobody is reading. +/// +/// Applied only to paths on **this** machine. `$HOME` here says nothing +/// about the home directory of a machine reached over ssh, so a remote +/// path is stored exactly as it was typed -- where a `~` somebody wrote +/// stays a `~`, and the remote shell is what expands it +/// (`ssh::quote_path`). +pub fn shorten_home(path: &str) -> String { + let Some(home) = std::env::home_dir() else { + return path.to_string(); + }; + let home = home.to_string_lossy(); + // The separator has to be part of the match, or `/home/bobby` would be + // read as a path inside `/home/bob`. + match path.strip_prefix(home.as_ref()) { + Some("") => "~".to_string(), + Some(rest) if rest.starts_with('/') => format!("~{rest}"), + _ => path.to_string(), + } +} + /// Runs a launch to completion and returns its stdout. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { @@ -182,3 +208,29 @@ impl Transport { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The two halves of a home-relative path, which have to be inverses: + /// what is stored is what the phone draws, and what the phone sends + /// back is what a process is started in. + #[test] + fn a_home_path_shortens_and_expands_back() { + let Some(home) = std::env::home_dir() else { + return; + }; + let full = home.join("repos/ai-app-2"); + let full = full.to_string_lossy(); + assert_eq!(shorten_home(&full), "~/repos/ai-app-2"); + assert_eq!(shorten_home(&home.to_string_lossy()), "~"); + assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref())); + + // Not a prefix match on the characters: a sibling directory whose + // name merely starts with the home directory's is not inside it. + let sibling = format!("{}-backup/notes", home.to_string_lossy()); + assert_eq!(shorten_home(&sibling), sibling); + assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); + } +} diff --git a/server/src/ssh.rs b/server/src/ssh.rs index 890852d..e47a511 100644 --- a/server/src/ssh.rs +++ b/server/src/ssh.rs @@ -10,7 +10,7 @@ //! `~/.ssh/config`, agents, and jump hosts all keep working and there is //! only one place to configure connections (PLAN.md, rule 23). -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::SshConfig; @@ -50,7 +50,15 @@ pub fn command( let mut command = Command::new(program); command.args(args); if let Some(cwd) = cwd { - command.current_dir(cwd); + // Expanded here for the same reason `quote_path` expands it on + // the far side: a working directory typed as `~/repos/ai-app` + // has to mean the same thing whichever machine runs it. There + // is no shell in this branch, so nothing else would -- + // `current_dir` would be handed the literal one-character + // directory `~`, and the session would fail to start with an + // error naming a path nobody typed. Only the cwd, matching + // the remote side, where arguments stay literal. + command.current_dir(expand_home(cwd)); } return command; }; @@ -101,6 +109,30 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String { script } +/// A path with a leading `~` replaced by this machine's home directory. +/// +/// The local half of the rule [`quote_path`] states for the remote one, and +/// the two are deliberately the same shape: the tilde is expanded, `~user` +/// is not (there is no portable expansion for another account's home), and +/// nothing else in the path gains a meaning. A machine with no home +/// directory at all leaves the path alone, which fails with the operating +/// system's own message rather than with a guess. +pub(crate) fn expand_home(path: &Path) -> PathBuf { + let Some(rest) = path.to_str().and_then(|p| { + if p == "~" { + Some("") + } else { + p.strip_prefix("~/") + } + }) else { + return path.to_path_buf(); + }; + match std::env::home_dir() { + Some(home) => home.join(rest), + None => path.to_path_buf(), + } +} + /// Quotes a path, expanding a leading `~` and nothing else. /// /// [`quote`] is right for every other word crossing to the remote side and @@ -247,6 +279,31 @@ mod tests { ); } + /// The same character, on the transport with no shell to expand it. + /// + /// The local branch runs the program directly, so a working directory + /// of `~/repos/ai-app` would reach `current_dir` as the literal + /// one-character directory `~` -- a session that fails to start, + /// naming a path nobody typed. The two transports have to agree about + /// what a tilde means or a path is only portable by accident. + #[test] + fn a_local_cwd_expands_its_tilde_the_same_way() { + let Some(home) = std::env::home_dir() else { + return; + }; + assert_eq!( + expand_home(Path::new("~/repos/ai-app")), + home.join("repos/ai-app") + ); + assert_eq!(expand_home(Path::new("~")), home); + // Leading only, and its own segment only -- `quote_path`'s rule. + assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x")); + assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x")); + + let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work"))); + assert_eq!(local.get_current_dir(), Some(home.join("work").as_path())); + } + #[test] fn shell_metacharacters_cross_as_data_not_syntax() { // Expanding $HOME must not open a door for anything else: the rest