From 32b2a4871a320a56dcea1c5b158a7907859be1ea Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 19:56:36 -0400 Subject: [PATCH 1/3] Read a timeout in units, share one usage answer, and let a tilde mean home The composer's settings row is outlined bubbles opening round menus, and the message box is a TextFieldValue so anything put into it without being typed -- a draft, a share, a slash command -- leaves the cursor at the end. A tap that puts a text selection away no longer also collapses the card the text was drawn in: every open and close on the session screen goes through one guard that spends such a press on the selection. The usage bar and the usage dialog were two polls of one measurement and disagreed for up to a minute at a time; they are one feed now, and the countdown rounds up to the minute in the one place both read. A working directory typed as ~/repos/ai-app was four literal characters on the local transport and as an argument on both, so the existence check refused every home-relative path. It is checked by entering the directory now, expanded for a local spawn the way the remote shell expands it, and stored short so the phone draws what somebody would write. Co-Authored-By: Claude Opus 5 --- TODO.md | 29 ++---- app/androidApp/src/main/AndroidManifest.xml | 12 ++- .../main/kotlin/com/example/aiapp/Bubble.kt | 54 ++++++++++++ .../kotlin/com/example/aiapp/Durations.kt | 38 ++++++++ .../main/kotlin/com/example/aiapp/Markdown.kt | 2 +- .../kotlin/com/example/aiapp/NerdIcons.kt | 21 +++++ .../com/example/aiapp/ResetCountdown.kt | 22 +++-- .../kotlin/com/example/aiapp/SessionScreen.kt | 88 +++++++++++++++---- .../com/example/aiapp/SessionUsageBar.kt | 60 ++++++++++--- .../kotlin/com/example/aiapp/ToolInput.kt | 6 +- .../com/example/aiapp/TranscriptList.kt | 9 +- .../kotlin/com/example/aiapp/UsageDialog.kt | 38 +++----- server/src/routes.rs | 12 ++- server/src/session/import.rs | 9 +- server/src/setups.rs | 52 +++++++++++ server/src/ssh.rs | 61 ++++++++++++- 16 files changed, 414 insertions(+), 99 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt diff --git a/TODO.md b/TODO.md index b93385f..74e8cc1 100644 --- a/TODO.md +++ b/TODO.md @@ -3,19 +3,12 @@ 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, @@ -37,22 +30,12 @@ one in place when it turns out to need a decision. 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. +- [ ] Switching models must not warn when there is no context for the warning to + matter — e.g. straight after a clear. -## 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..3a4512d 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/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/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 a95074f..37981ba 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -225,7 +225,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..66093c7 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 @@ -190,6 +193,24 @@ fun GlyphButton( } } +/** + * 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) + } +} + /** * One icon, drawn as text. * 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/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index ea2438c..767f8a9 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 } @@ -1064,7 +1095,7 @@ fun SessionScreen( } 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 +1103,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 +1118,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 +1163,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 +1373,7 @@ fun SessionScreen( units = units, state = listState, moreHistory = moreHistory, + selection = selection, modifier = Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, below = { @@ -1720,9 +1753,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 +1784,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 +1801,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 +1921,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 +1940,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( @@ -2283,6 +2321,17 @@ private fun QuestionRow( } } +/** + * [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 +2363,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 +2402,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/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 4ed473f..440b785 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -31,8 +31,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. */ @@ -84,7 +84,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/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/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 From 8dcd2cb70842768bb3b1c887e8deb62ddb920745 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 20:04:48 -0400 Subject: [PATCH 2/3] Answer a question card as one act, and mark the press that made it Picking an option marked nothing until the answer had crossed the tunnel, been recorded and come back as an event, so the card sat unchanged for most of a second after a tap. What the reader has picked is now the card's own state and shows at once; a Submit at the foot sends every question the tool is waiting on, greyed until all of them have an answer and a spinner while the request is out. Several questions are paged rather than stacked, with the count and a pair of arrows on the right, because three questions with four described options each is several screens and the reader scrolls past the one they are answering to reach the button that sends it. A permission ask keeps its single tap -- two bare words are not worth a submit step -- and marks what was pressed until the request settles, so the mark either stands on the recorded answer or goes away with the failure. Chevron draws all four directions from one description of the shape, since the pager needed two more of them. Co-Authored-By: Claude Opus 5 --- TODO.md | 12 +- app/androidApp/src/main/AndroidManifest.xml | 2 +- .../kotlin/com/example/aiapp/AskQuestion.kt | 233 ++++++++++++++---- .../main/kotlin/com/example/aiapp/Chevron.kt | 61 +++-- .../kotlin/com/example/aiapp/NerdIcons.kt | 23 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 57 ++--- .../main/kotlin/com/example/aiapp/ToolRows.kt | 38 ++- 7 files changed, 299 insertions(+), 127 deletions(-) diff --git a/TODO.md b/TODO.md index 74e8cc1..a7df47d 100644 --- a/TODO.md +++ b/TODO.md @@ -10,7 +10,9 @@ one in place when it turns out to need a decision. (`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). + 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. - [ ] 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. @@ -22,14 +24,6 @@ one in place when it turns out to need a decision. - [ ] 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. - ## Session settings - [ ] Autocompact belongs in session settings; empty disables it, which is the diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index 3a4512d..cb94924 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ 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 + 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/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/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/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 66093c7..9482384 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -183,13 +183,34 @@ 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() } } 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 767f8a9..9c28de3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -1094,6 +1094,19 @@ 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.text.trim() val attachments = pendingAttachments @@ -1509,16 +1522,7 @@ fun SessionScreen( else expandedTools + id } }, - onAnswer = { questionId, answers -> - act { - answerQuestion( - settings, - summary.id, - questionId, - answers, - ) - } - }, + onAnswer = ::answerAll, image = { ref -> SessionImage( settings, @@ -1568,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, @@ -1588,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, @@ -1691,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), ) @@ -2310,13 +2296,14 @@ 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) } } } 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..b672633 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, @@ -398,9 +401,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 +415,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 +436,18 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List + pressed = label + onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null } + }, + ) } /** From a383c19dd5a1ddd7627a33f361504f24a0c5e02f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 3 Sep 2026 20:17:12 -0400 Subject: [PATCH 3/3] Colour what a shell printed, swipe back, and let a stopped session take a setting Bash output arrived with its escape sequences in it, so a coloured diff or test run was line noise around the thing being read. The sequences that decide how text looks are spans now and every other one is dropped, with a carriage return honoured the way a terminal honours it so a progress bar shows its final state rather than every state it passed through. A rightward drag anywhere on a session, spawn or settings screen steps back, following the finger so it can be abandoned. It loses every argument: a child that consumes horizontal drags -- a wide fence, a table, a selection -- has already taken the gesture before this sees it. Changing the model or the permission mode of a session with nothing running was refused, in words about the driver, while the config had already taken the value that its next start will use. Both now announce the stored setting instead, through one function, since which of the pair it is does not change the rule. The model-switch warning no longer fires after a clear: the server reports the context as unmeasured rather than zero afterwards, and the fallback reading counted the whole conversation still on screen. An image loading shows a spinner in the space it is about to fill, in the transcript and in the composer's attachments alike. Co-Authored-By: Claude Opus 5 --- TODO.md | 19 +- .../src/main/kotlin/com/example/aiapp/Ansi.kt | 313 ++++++++++++++++++ .../main/kotlin/com/example/aiapp/AppRoot.kt | 23 +- .../com/example/aiapp/PendingAttachments.kt | 18 +- .../kotlin/com/example/aiapp/SessionImage.kt | 66 +++- .../kotlin/com/example/aiapp/SessionScreen.kt | 16 +- .../kotlin/com/example/aiapp/SwipeBack.kt | 70 ++++ .../main/kotlin/com/example/aiapp/Theme.kt | 41 +++ .../main/kotlin/com/example/aiapp/ToolRows.kt | 10 +- server/src/session/mod.rs | 123 ++++++- 10 files changed, 651 insertions(+), 48 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SwipeBack.kt diff --git a/TODO.md b/TODO.md index a7df47d..b62edb4 100644 --- a/TODO.md +++ b/TODO.md @@ -13,23 +13,16 @@ one in place when it turns out to need a decision. 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. -- [ ] 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. - [ ] 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). - ## 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. -- [ ] Switching models must not warn when there is no context for the warning to - matter — e.g. straight after a clear. + 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. 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/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/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 9c28de3..617a439 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -2121,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, @@ -2134,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() } /** 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 e83c45c..3910b94 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -27,7 +27,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) @@ -218,6 +220,45 @@ fun catppuccinSyntax(): SyntaxTheme = mark = Mocha.Sky.toArgb(), ) +/** + * 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/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index b672633..3ef2ddc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -383,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, ) 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.