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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 20:17:12 -04:00
1 parent 8dcd2cb708
commit a383c19dd5
10 files changed
+651 -48

No files matched your search

+6 -13
View File
@@ -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 itself works — only the highlight is missing). Waiting on the app-c7
session, which is replacing the highlighter (`HIGHLIGHTER_PLAN.md`) and session, which is replacing the highlighter (`HIGHLIGHTER_PLAN.md`) and
owns `CodeFence.kt` until it pushes; it confirmed this item is ours. 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 - [ ] Messages received from other agents are inconsistent — sometimes they
appear, sometimes they don't. 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 ## Session settings
- [ ] Autocompact belongs in session settings; empty disables it, which is the - [ ] Autocompact belongs in session settings; empty disables it, which is the
default. default. **Needs a decision before building** — nothing called autocompact
- [ ] Changing the model on a stopped provider should be possible, stored, and exists yet on either side. `PLAN.md` has it only as a planned pi-driver
applied the next time it starts. feature (`set_auto_compaction`), and Claude Code runs its own. So this is
- [ ] Switching models must not warn when there is no context for the warning to a new server feature, and the open questions are what the empty-or-not
matter — e.g. straight after a clear. value *is* (a token count? a percentage of the context window?) and which
drivers it applies to.
@@ -0,0 +1,313 @@
package com.example.aiapp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
/**
* The sixteen colours a terminal program names, and the two it assumes.
*
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from
* somewhere else, but the two are not one table and must not become one -- adding a syntax role to
* this list would silently move `ls`'s directory blue.
*/
data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
val colours: List<Color>,
/** What uncoloured text is, needed only where a style has to state a colour. */
val foreground: Color,
/** What the text sits on, needed for reverse video. */
val background: Color,
)
/**
* What a tool printed, with its terminal styling applied and everything else taken out.
*
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of
* every green word. Stripping them all would be the other half-answer -- colour is often the whole
* of what a diff, a test run or a linter is saying.
*
* So the sequences that decide how text *looks* become spans, and every other one is dropped.
* Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript
* is a scrolling document, and "go to column 40" has no meaning here that is better than nothing.
*
* A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through, which was tens of lines run together.
*
* Not a composable, and the palette is a parameter: this can then be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it.
*/
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
val runs = mutableListOf<Run>()
var sgr = Sgr.PLAIN
var at = 0
val plain = StringBuilder()
fun flush() {
if (plain.isNotEmpty()) {
runs.add(Run(plain.toString(), sgr.span(palette)))
plain.clear()
}
}
while (at < text.length) {
val c = text[at]
when {
c == ESC -> {
flush()
at =
skipEscape(text, at) { params, final ->
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line; one before a newline is the other half of
// a Windows line ending and has nothing to rewrite.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
at++
}
c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor
// move does.
c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c)
at++
}
else -> at++
}
}
flush()
return buildAnnotatedString {
runs.forEach { run ->
if (run.style == null) {
append(run.text)
} else {
val pushed = pushStyle(run.style)
append(run.text)
pop(pushed)
}
}
}
}
/** One stretch of text that shares a style. */
private class Run(val text: String, val style: SpanStyle?)
/** Throws away everything written since the last line break, as a carriage return does. */
private fun dropLine(runs: MutableList<Run>) {
while (runs.isNotEmpty()) {
val last = runs.removeAt(runs.size - 1)
val breakAt = last.text.lastIndexOf('\n')
if (breakAt >= 0) {
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
return
}
}
}
private const val ESC = '\u001B'
private const val BELL = '\u0007'
/**
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
*
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
* not recognise would otherwise have its body printed as ordinary text, which is worse than the
* escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes
* (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones.
*/
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1
return when (next) {
'[' -> {
var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving
// looks like: drop the fragment rather than printing it, and the whole sequence
// arrives with the next delta.
text.length
} else {
onCsi(text.substring(at + 2, end), text[end])
end + 1
}
}
']',
'P',
'X',
'^',
'_' -> {
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
var end = at + 2
while (end < text.length) {
if (text[end] == BELL) return end + 1
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
end++
}
text.length
}
else -> at + 2
}
}
/** The bytes that end a CSI sequence. */
private val CSI_FINAL = '@'..'~'
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
private data class Sgr(
val fg: Color?,
val bg: Color?,
val bold: Boolean,
val dim: Boolean,
val italic: Boolean,
val underline: Boolean,
val strike: Boolean,
val reverse: Boolean,
) {
/** Null while nothing is set, so unstyled output costs no spans at all. */
fun span(palette: AnsiPalette): SpanStyle? {
if (this == PLAIN) return null
val front = if (reverse) bg ?: palette.background else fg
val back = if (reverse) fg ?: palette.foreground else bg
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
val stated = front ?: palette.foreground.takeIf { dim }
return SpanStyle(
color =
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
background = back ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else null,
fontStyle = if (italic) FontStyle.Italic else null,
textDecoration =
when {
underline && strike ->
TextDecoration.combine(
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
)
underline -> TextDecoration.Underline
strike -> TextDecoration.LineThrough
else -> null
},
)
}
/**
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
*
* A code this does not model is ignored rather than reset from: the program meant something by
* it, and starting again would also drop the codes beside it that are understood.
*/
fun apply(params: String, palette: AnsiPalette): Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
var state = this
var at = 0
while (at < codes.size) {
val code = codes[at]
state =
when (code) {
0 -> PLAIN
1 -> state.copy(bold = true)
2 -> state.copy(dim = true)
3 -> state.copy(italic = true)
4 -> state.copy(underline = true)
7 -> state.copy(reverse = true)
9 -> state.copy(strike = true)
21,
22 -> state.copy(bold = false, dim = false)
23 -> state.copy(italic = false)
24 -> state.copy(underline = false)
27 -> state.copy(reverse = false)
29 -> state.copy(strike = false)
in 30..37 -> state.copy(fg = palette.colours[code - 30])
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
in 40..47 -> state.copy(bg = palette.colours[code - 40])
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
39 -> state.copy(fg = null)
49 -> state.copy(bg = null)
38,
48 -> {
val (colour, last) = extendedColour(codes, at, palette)
at = last
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
}
else -> state
}
at++
}
return state
}
companion object {
val PLAIN =
Sgr(
fg = null,
bg = null,
bold = false,
dim = false,
italic = false,
underline = false,
strike = false,
reverse = false,
)
}
}
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
private const val DIM_ALPHA = 0.65f
/**
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
*
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
* the same red.
*/
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
when (codes.getOrNull(at + 1)) {
5 -> {
val n = codes.getOrNull(at + 2)
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
}
2 -> {
val r = codes.getOrNull(at + 2)
val g = codes.getOrNull(at + 3)
val b = codes.getOrNull(at + 4)
if (r == null || g == null || b == null) null to at + 1
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
}
else -> null to at + 1
}
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
when {
n < 0 -> palette.foreground
n < 16 -> palette.colours[n]
n < 232 -> {
val i = n - 16
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
}
n < 256 -> {
val grey = 8 + (n - 232) * 10
Color(grey, grey, grey)
}
else -> palette.foreground
}
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
@@ -198,16 +198,21 @@ fun AppRoot(
// row key. Only reachable since a notification can move straight from one session to // 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. // another; every other way here passes through [Screen.Main], which disposes it anyway.
key(here.summary.id) { key(here.summary.id) {
SessionScreen( // The gesture goes on a box around the screen rather than inside it, so it is the
settings = current, // outermost thing in the tree and everything within has already had its chance at
summary = here.summary, // the drag. See [swipeBack]. No imePadding here, for the reason above.
onBack = goToMain, Box(Modifier.swipeBack(goToMain)) {
share = share, SessionScreen(
onShareTaken = { share = null }, settings = current,
) summary = here.summary,
onBack = goToMain,
share = share,
onShareTaken = { share = null },
)
}
} }
is Screen.Spawn -> is Screen.Spawn ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding().swipeBack(goToMain)) {
SpawnScreen( SpawnScreen(
settings = current, settings = current,
onSpawned = { spawned -> onSpawned = { spawned ->
@@ -218,7 +223,7 @@ fun AppRoot(
) )
} }
is Screen.Settings -> is Screen.Settings ->
Box(Modifier.imePadding()) { Box(Modifier.imePadding().swipeBack(goToMain)) {
SettingsScreen( SettingsScreen(
existing = current, existing = current,
onSaved = { saved -> onSaved = { saved ->
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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 // 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. // is worth waiting for and the other never resolves.
null -> null ->
Text( if (failed) {
if (failed) "!" else "", Text(
style = MaterialTheme.typography.bodyLarge, "!",
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 -> else ->
Image( Image(
bitmap = image, bitmap = image,
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -20,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
@@ -94,14 +97,19 @@ fun SessionImage(
val heightPx = with(LocalDensity.current) { height.roundToPx() } val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) { 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 -> null ->
Text( if (failed) {
// Two states, not one: an image still arriving and an image that will never Text(
// arrive look nothing alike to a reader who can do something about the second. "[image $ref unavailable]",
if (failed) "[image $ref unavailable]" else "[loading image…]", style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, )
) } else {
LoadingImage(height)
}
else -> else ->
Image( Image(
bitmap = image, bitmap = image,
@@ -153,17 +161,51 @@ fun SessionImageViewer(
// coming. Stated in white because this box paints its own black behind them and a // 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. // theme colour would be picked against a surface that is not there.
null -> null ->
Text( if (failed) {
if (failed) "Image $ref is unavailable" else "Loading image…", Text(
color = Color.White, "Image $ref is unavailable",
style = MaterialTheme.typography.bodyMedium, 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) 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. * Four lines of the body style the transcript is set in.
* *
@@ -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 * 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. * 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 * Where the figure is *unknown* rather than zero the fallback is whether anything has been said
* anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the * **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 * 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( private fun worthWarningAbout(
status: String, status: String,
@@ -2134,7 +2142,7 @@ private fun worthWarningAbout(
when { when {
status == "exited" -> false status == "exited" -> false
contextTokens != null -> contextTokens > 0 contextTokens != null -> contextTokens > 0
else -> items.isNotEmpty() else -> items.asReversed().takeWhile { it !is TranscriptItem.ClearedNote }.isNotEmpty()
} }
/** /**
@@ -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
@@ -27,7 +27,9 @@ private object Mocha {
val Sky = Color(0xFF89DCEB) val Sky = Color(0xFF89DCEB)
val Blue = Color(0xFF89B4FA) val Blue = Color(0xFF89B4FA)
val Lavender = Color(0xFFB4BEFE) val Lavender = Color(0xFFB4BEFE)
val Pink = Color(0xFFF5C2E7)
val Text = Color(0xFFCDD6F4) val Text = Color(0xFFCDD6F4)
val Subtext1 = Color(0xFFBAC2DE)
val Subtext0 = Color(0xFFA6ADC8) val Subtext0 = Color(0xFFA6ADC8)
val Overlay0 = Color(0xFF6C7086) val Overlay0 = Color(0xFF6C7086)
val Surface2 = Color(0xFF585B70) val Surface2 = Color(0xFF585B70)
@@ -218,6 +220,45 @@ fun catppuccinSyntax(): SyntaxTheme =
mark = Mocha.Sky.toArgb(), 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. * A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
*/ */
@@ -383,9 +383,17 @@ fun ToolCard(
// face it was written for: this is column-aligned far more often than it is // 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 // prose -- a directory listing, a diff, a table of numbers -- and a
// proportional font silently destroys the alignment that carried the meaning. // 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)) { RawBlock(Modifier.padding(top = 2.dp)) {
Text( Text(
tool.output, styled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
) )
+119 -4
View File
@@ -1217,9 +1217,16 @@ impl SessionManager {
// change, and as an error if it cannot. The config above is a // change, and as an error if it cannot. The config above is a
// different question -- what to launch this session with next // different question -- what to launch this session with next
// time -- and it is answered by the request. // time -- and it is answered by the request.
session.ask("change how much it asks", |driver| { announce_or_ask(
driver.set_permission_mode(mode) 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(()) Ok(())
} }
@@ -1323,7 +1330,16 @@ impl SessionManager {
if let Some(session) = inner.live.get(id) { if let Some(session) = inner.live.get(id) {
// See `set_session_permission_mode`: the driver reports what // See `set_session_permission_mode`: the driver reports what
// it is set to, this only asks. // 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(()) Ok(())
} }
@@ -1685,6 +1701,37 @@ impl SessionManager {
/// to the one process, and every line it wrote was then translated once per /// 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 /// reader -- three presses put three interleaved copies of one reply on
/// screen. /// 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 { fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus {
if status == SessionStatus::Exited && adoptable(session_dir) { if status == SessionStatus::Exited && adoptable(session_dir) {
SessionStatus::Unknown SessionStatus::Unknown
@@ -3613,6 +3660,74 @@ mod tests {
std::fs::write(path, rewritten).expect("write transcript"); 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 /// Stopping and starting a session is about its *process*, and the two
/// refusals are the whole of what keeps starting one from becoming a /// refusals are the whole of what keeps starting one from becoming a
/// second one on the same conversation. /// second one on the same conversation.