Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-09-03 21:07:07 -04:00
commit f6bee1b8a5
10 files changed
+286 -19

No files matched your search

+22 -9
View File
@@ -5,16 +5,29 @@ one in place when it turns out to need a decision.
## App — transcript ## App — transcript
- [ ] Tapping an expanded agent message card should close it. — a tap that - [ ] Text inside code blocks does not highlight when selected. **Measured, and
clears a selection now spends itself on that and nothing else it does** — the selection is drawn, but over the near-black surface a code
(`SessionScreen.expanding`); still to check on the emulator whether a tap block and a tool's output sit on, Material's default 40%-alpha tint
with *no* selection reaches an opened peer card at all. composites to a barely-there smudge, much weaker than the same selection
- [ ] Text inside code blocks does not highlight when selected (selection over a reply. The app now states its own selection colours
itself works — only the highlight is missing). Waiting on the app-c7 (`AiAppSelectionColors`), which took the fill from #5B4C73 to #776394 on
session, which is replacing the highlighter (`HIGHLIGHTER_PLAN.md`) and that surface. Worth confirming this was the complaint rather than a
owns `CodeFence.kt` until it pushes; it confirmed this item is ours. selection that draws *nothing* on the phone.
- [ ] Text inside an opened peer message or memory note cannot be selected at
all — the heading of the same card can, and so can a tool call's output,
so it is the markdown text specifically. Pre-existing (measured against
the build before this session's changes, by stashing them). It
contradicts AGENTS.md's "all transcript text is selectable".
- [ ] 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. **Needs a rig.** Read the code rather than
measured: a live Claude session only learns of a peer message from the
`origin` object on a turn's `result`
(`session/claude/translate.rs`), which the CLI attaches to a turn the
message *started*. So a message that arrives mid-turn, or a second one
within one turn, has nowhere to be reported — while an imported session,
which syncs from the CLI's own file, picks up every one of them. That
would show exactly as "sometimes". Confirming it means driving a real
stream-json session and sending it messages in both states.
## Session settings ## Session settings
@@ -71,8 +71,10 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
if (final == 'm') sgr = sgr.apply(params, palette) 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 bare carriage return rewrites the line. One before a newline is the other half
// a Windows line ending and has nothing to rewrite. // of a Windows line ending: it rewrites nothing, and it is dropped rather than kept,
// since that pair is one line break and the return itself would draw as a stray
// control character.
c == '\r' && text.getOrNull(at + 1) != '\n' -> { c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush() flush()
dropLine(runs) dropLine(runs)
@@ -13,8 +13,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -92,7 +94,12 @@ class MainActivity : ComponentActivity() {
NotificationService.sync(this) NotificationService.sync(this)
setContent { setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into
// the code block under it would otherwise change colour halfway. See
// [AiAppSelectionColors].
MaterialTheme(colorScheme = AiAppColors) { MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Box( Box(
modifier = modifier =
@@ -149,6 +156,7 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
}
// launchMode="singleTop": an enrollment scan, or a notification tapped // launchMode="singleTop": an enrollment scan, or a notification tapped
// while the app is open, lands here rather than in a second activity // while the app is open, lands here rather than in a second activity
@@ -1,9 +1,13 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
@@ -88,17 +92,41 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
content.buildMarkdownAnnotatedString(node, style, settings) content.buildMarkdownAnnotatedString(node, style, settings)
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour. // The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
BasicText( BasicText(
text = text, text = text,
modifier = modifier =
modifier.pointerInput(text) { // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
detectTapGestures { position -> // second one has to be answered from inside the text rather than left to the card.
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures modifier.pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became something somebody
// else's -- a scroll, or a press held past the long-press timeout, which is
// how a selection starts. The timeout is the load-bearing half: without it a
// press held for a second and released was still an up with nothing consumed,
// so holding a peer message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
} ?: return@awaitEachGesture
val url = text.linkAt(layout.value, up.position)
when {
url != null -> {
up.consume()
uriHandler.openUri(url) uriHandler.openUri(url)
} }
onPlainTap != null -> {
up.consume()
onPlainTap()
}
}
}
}, },
style = style, style = style,
color = { color }, color = { color },
@@ -106,6 +134,39 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
) )
} }
/**
* What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in,
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch out of its component table, so nothing between a card and
* the text inside it is ours to pass a parameter through; the renderer already hands its colours,
* its typography and its components down the same way.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured on the emulator against an opened peer message: with a handler on
* the text -- consuming or not -- a tap on its words did nothing at all, and with the handler
* removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by
* pressing its words unless the words do the shutting, and "nothing happens when I press it" is
* indistinguishable from a card that has stopped working.
*
* Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh
* lambda per composition would invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap].
*
* The identity stays put while the behaviour follows the latest [onTap], which is what keeps
* providing it from invalidating the text under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
val latest = rememberUpdatedState(onTap)
return remember { { latest.value() } }
}
/** /**
* The address under [position], if a link's glyph is there rather than merely nearest to it. * The address under [position], if a link's glyph is there rather than merely nearest to it.
* *
@@ -12,6 +12,7 @@ import androidx.compose.material3.Card
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
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -123,7 +124,12 @@ fun MemoryNote(
) )
} }
} }
if (expanded) MarkdownText(note.text, replies, Modifier.padding(top = 4.dp)) // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
if (expanded) {
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
}
}
} }
} }
} }
@@ -13,6 +13,7 @@ import androidx.compose.material3.CardDefaults
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
import androidx.compose.runtime.CompositionLocalProvider
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.draw.clip
@@ -87,11 +88,16 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl
onPress = onToggle, onPress = onToggle,
) )
) { ) {
// The gap the card's own column used to provide between its heading and its prose, and // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// between one block and the next -- inside the piece, so the card's fill runs through it. // Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose,
// and between one block and the next -- inside the piece, so the card's fill runs
// through it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing)) MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
} }
} }
}
/** /**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it. * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
@@ -281,6 +281,13 @@ fun SessionScreen(
// have to ask whether anything is selected before they treat a tap as their own -- see // have to ask whether anything is selected before they treat a tap as their own -- see
// [expanding]. // [expanding].
val selection = rememberSelectionState() val selection = rememberSelectionState()
// Read here, at composition, rather than inside [expanding] at the moment of the click. The
// container clears the selection from the very press that a card then reads as its own, a few
// milliseconds earlier and in the same event -- so a card asking the live state at its click
// always hears "nothing is selected", and a tap meant to put a selection away also shut the
// tool call the words were in. This value is whatever was true as of the last frame, which is
// what the reader was looking at when they touched the screen.
val selecting = selection.selectedTexts.isNotEmpty()
var expandedTools by remember { mutableStateOf(setOf<String>()) } var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's // Which runs of adjacent tool calls are open. Keyed by the first call's
// id, so a group survives more calls arriving after it. // id, so a group survives more calls arriving after it.
@@ -509,7 +516,7 @@ fun SessionScreen(
* hold for only some of them. * hold for only some of them.
*/ */
fun expanding(toggle: () -> Unit) { fun expanding(toggle: () -> Unit) {
if (selection.selectedTexts.isNotEmpty()) { if (selecting) {
selection.clear() selection.clear()
return return
} }
@@ -1,5 +1,6 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -257,6 +258,26 @@ fun ansiPalette(): AnsiPalette =
background = Mocha.Crust, background = Mocha.Crust,
) )
/**
* What a selection looks like, stated rather than left to Material's default.
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
* selecting a line of code looks like nothing happened even though the selection is there and
* copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
* means something must carry its own contrast instead of borrowing it from the surface it happens
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
* starts competing with the syntax colours it sits behind, which are the thing being read.
*/
val AiAppSelectionColors =
TextSelectionColors(
handleColor = Mocha.Mauve,
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
)
/** /**
* 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.
*/ */
@@ -0,0 +1,87 @@
package com.example.aiapp
import androidx.compose.ui.graphics.Color
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* What survives a terminal's escape sequences, and what the styling reads as.
*
* Asserted as the plain text and as the style over a named substring, rather than as span offsets,
* so a failure prints the output that was got wrong instead of a pair of numbers.
*/
class AnsiTest {
private val palette =
AnsiPalette(
colours = (0..15).map { Color(it, 0, 0) },
foreground = Color(1f, 1f, 1f),
background = Color(0f, 0f, 0f),
)
private fun styled(text: String) = ansiStyled(text, palette)
/** The style covering the first character of [word], or null where nothing styles it. */
private fun styleOver(text: String, word: String) =
styled(text).let { annotated ->
val at = annotated.text.indexOf(word)
assertTrue(at >= 0, "no \"$word\" in ${annotated.text}")
annotated.spanStyles.firstOrNull { at >= it.start && at < it.end }?.item
}
private val esc = '\u001B'
@Test
fun `a colour becomes a span and the sequence itself disappears`() {
val text = "plain ${esc}[31mred${esc}[0m plain"
assertEquals("plain red plain", styled(text).text)
assertEquals(palette.colours[1], styleOver(text, "red")?.color)
assertNull(styleOver(text, "plain"))
}
@Test
fun `bright, background and 256-colour forms all reach the same table`() {
assertEquals(palette.colours[9], styleOver("${esc}[91mx", "x")?.color)
assertEquals(palette.colours[4], styleOver("${esc}[44mx", "x")?.background)
// The first sixteen of the 256-colour table are the palette's own, so a program that
// spells a colour either way gets the same one.
assertEquals(palette.colours[1], styleOver("${esc}[38;5;1mx", "x")?.color)
// And past them, xterm's cube: 16 is its black corner, 231 its white one.
assertEquals(Color(0, 0, 0), styleOver("${esc}[38;5;16mx", "x")?.color)
assertEquals(Color(255, 255, 255), styleOver("${esc}[38;5;231mx", "x")?.color)
assertEquals(Color(10, 20, 30), styleOver("${esc}[38;2;10;20;30mx", "x")?.color)
}
@Test
fun `everything that is not styling is dropped rather than printed`() {
// A cursor move, an erase, an OSC window title with its bell, and a bare two-character
// escape. None of them mean anything in a scrolling document, and all of them would be
// line noise if the escape alone were stripped and the body left behind.
val text = "a${esc}[2Jb${esc}[Kc${esc}]0;a titled${esc}=e"
assertEquals("abcde", styled(text).text)
}
@Test
fun `a carriage return rewrites its line, as it does on a terminal`() {
// What a progress bar looks like: every state it passed through, ending on the last.
assertEquals("done\n", styled("10%\r50%\rdone\n").text)
// The line before it is untouched. A Windows line ending rewrites nothing and is not
// kept either: it is one line break, and passing the carriage return through would draw
// a stray control character in the middle of the output.
assertEquals("kept\nlast", styled("kept\r\nfirst\rlast").text)
}
@Test
fun `a sequence cut off mid-stream takes no text with it`() {
// Output still arriving ends anywhere, including inside an escape. The fragment goes and
// the whole sequence arrives with the next delta.
assertEquals("text ", styled("text ${esc}[3").text)
}
@Test
fun `unstyled text costs no spans at all`() {
assertEquals(0, styled("nothing to do here").spanStyles.size)
assertEquals(0, styled("a${esc}[2Jb").spanStyles.size)
}
}
@@ -0,0 +1,56 @@
package com.example.aiapp
import java.time.Duration
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The two ways a span of time is written here, and the rule each of them follows.
*
* Both are read off a screen to make a decision -- how long a tool call may take, how long a quota
* has left -- so what matters is that the shortest form that answers the question is what appears.
*/
class DurationsTest {
@Test
fun `under a minute is the largest unit alone`() {
assertEquals("30ms", formatMillis(30))
assertEquals("999ms", formatMillis(999))
assertEquals("1s", formatMillis(1000))
assertEquals("2.5s", formatMillis(2500))
// One decimal, rounded rather than cut: 2.46s is nearer two and a half than two and four.
assertEquals("2.5s", formatMillis(2460))
assertEquals("59.9s", formatMillis(59_900))
}
@Test
fun `a minute or more is every unit that has something in it`() {
// The figure this rule was written for: a tool timeout, which arrives as milliseconds and
// is unreadable as 480000.
assertEquals("8m", formatMillis(480_000))
assertEquals("1m", formatMillis(60_000))
assertEquals("1m 30s", formatMillis(90_000))
assertEquals("5d 12h 4m", formatMillis(475_440_000))
// Empty units are left out rather than written as zero: the labels say which is which,
// and "5d 0h 4m" is only longer.
assertEquals("5d 4m", formatMillis(432_240_000))
}
@Test
fun `only a whole number of milliseconds is rewritten`() {
assertEquals("8m", formatMillisText(" 480000 "))
// A timeout a tool expressed some other way is its own words, passed through rather than
// guessed at.
assertEquals("2 minutes", formatMillisText("2 minutes"))
assertEquals("", formatMillisText(""))
}
@Test
fun `a countdown rounds up, so it never reports a minute already spent`() {
assertEquals("3h 13m", formatSpan(Duration.ofMinutes(192).plusSeconds(50)))
// Exactly on a minute is already the answer and is not pushed past it.
assertEquals("3h 12m", formatSpan(Duration.ofMinutes(192)))
assertEquals("12m", formatSpan(Duration.ofMinutes(12)))
// Rounding up carries, so a day's worth of minutes reads as a day.
assertEquals("1d 0h", formatSpan(Duration.ofHours(23).plusMinutes(59).plusSeconds(30)))
}
}