Close a card by pressing its words, and let a selection be put away
A markdown paragraph took every tap that landed on its glyphs, so an opened peer message or memory note could be shut anywhere except on the text -- which is most of it, and reads as a card that has stopped working. Measured on the emulator: with a handler on the text the tap did nothing at all, and with the handler removed the same tap shut the card. The words now do the shutting, through a composition local, since the renderer composes those paragraphs out of its own component table and there is nothing between the card and them to pass a parameter through. The link handler is bounded by the long-press timeout, so holding to select is not a tap. The other half is the tap that puts a selection away, which used to shut whatever card the words were in. The container clears the selection from that same press, milliseconds before the card reads it, so the answer is taken at composition instead -- what was true when the reader touched the screen. Selection colours are the app's own. Material's 40% of primary is a tint of whatever is behind it, and over the near-black a code block sits on it composited to a smudge, so selecting a line of code looked like nothing had happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
359649bc73
commit
acdf00ab1d
10 files changed
+286
-19
No files matched your search
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user