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.