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