Cap what the transcript draws, and let a LazySpan clip itself

Four things Iris asked for on 2026-09-08.

**A LazySpan no longer cares about masks.** It asserted that something
around it had called `.masked()` and refused to draw otherwise, which is
why a plain full-screen list -- the benchmark, any simple app -- panicked.
It cared only because it draws a row straddling an edge in full and relied
on somebody else to cut off the overhang; it clips itself to the box it
was offered now. Strictly stronger than the assert, which a mask *larger*
than the list's box satisfied while letting the overhang through anyway --
the fault it was written for. The transcript's `.masked()` wrapper goes
with it, and `Painter::is_masked` with that.

**Everything on the transcript screen is capped.** One rule in one place,
`client_core::text_cap`, mirrored as `TextCap.kt` with the same numbers so
a bench comparing the apps compares renderers rather than policies:

    a tool call's input    80 lines or 4 KiB   -> "Show all N lines"
    a tool call's output   80 lines or 4 KiB   -> (already was, in iris)
    a message             200 lines or 16 KiB  -> "Show all N lines"

The input is what the edit-card report needed: an Edit's old_string and
new_string arrive whole and are routinely the biggest text on screen.
Messages are capped in both apps, user and agent alike.

Three rules that took a screenshot to get right. A message is cut on a
block boundary, never mid-block -- cut to its own opening line a fence
renders as an empty panel, which reads as a fault rather than as a cap --
except a message that is one enormous block, which is truncated, since
dropping it would leave the row blank. A reply still streaming is never
capped. And the input's two blocks share one "Show all", while input and
output have their own.

**Compose stops wrapping raw text**, per Iris's call: a tool's leftover
input fields and its output pan sideways like the command already did.

`on_tap` and hold-the-edge move to `transcript-ui/src/tap.rs`, since a
message's "Show all" needs exactly what a tool card's tap already had.
This commit is contained in:
iris committed 2026-09-08 22:59:18 -04:00
1 parent 1318e149f5
commit afbc2ad132
18 files changed
+1254 -272

No files matched your search

@@ -295,6 +295,12 @@ fun SessionScreen(
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
// note opened and scrolled past is still open on the way back.
var openMemories by remember { mutableStateOf(setOf<String>()) }
// Which capped blocks the reader has asked to see whole: a tool call's input or output
// ([Capped]), and long messages, by the row key that identifies them. Held here rather than in
// the card or the row for `openMemories`' reason -- something opened and scrolled past is still
// open on the way back, and a lazy list drops the composition of anything off screen.
var shownWholeCalls by remember { mutableStateOf(setOf<Capped>()) }
var shownWholeRows by remember { mutableStateOf(setOf<Any>()) }
// The image being looked at full screen, by ref. Here rather than in the row that drew the
// thumbnail: a row regrouped underneath the reader takes its whole subtree with it.
var fullImage by remember { mutableStateOf<String?>(null) }
@@ -382,12 +388,14 @@ fun SessionScreen(
// Bumped when a cold reply's parses become ready, so the flatten runs again and can split it.
var warmedTick by remember { mutableIntStateOf(0) }
val units =
remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) }
remember(rows, expandedNotes, warmedTick, shownWholeRows) {
transcriptUnits(rows, replies, expandedNotes, shownWholeRows)
}
// The reply that just finished streaming is the one row whose parses nobody has made: pages
// warm before their fold lands, but nothing warms live deltas. Off the composing thread, then
// the tick re-flattens, so settling never costs a whole-message parse in a frame.
LaunchedEffect(rows) {
val cold = unwarmedReplies(rows, replies)
LaunchedEffect(rows, shownWholeRows) {
val cold = unwarmedReplies(rows, replies, shownWholeRows)
if (cold.isNotEmpty()) {
warm(replies, cold)
warmedTick++
@@ -550,6 +558,20 @@ fun SessionScreen(
toggle()
}
/**
* [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show
* all", which is its own list item under the message it reveals.
*
* Always the top edge, with no reading of which half was touched: the control is at the bottom
* of the row by construction, and the whole point of pressing it is that the text just above it
* continues. Held from the bottom instead, the revealed lines would push everything the reader
* had been reading up off the screen and leave them at the end of the message.
*/
fun expandAnchored(key: Any, reveal: () -> Unit) = expanding {
topEdgeHeld.key = key
reveal()
}
/**
* Whether the row holding transcript position [seq] is loaded, with older history behind it.
*
@@ -1486,6 +1508,15 @@ fun SessionScreen(
) { unit ->
when (unit) {
is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies)
is TranscriptUnit.ShowAll ->
ShowAllRow(unit.lines) {
// Anchored like every other control that changes a row's
// height: the reader is looking at the row this belongs to, and
// it is about to get much taller.
expandAnchored(unit.row) {
shownWholeRows = shownWholeRows + unit.row
}
}
is TranscriptUnit.PeerHead ->
PeerHeadRow(
unit.item,
@@ -1576,6 +1607,12 @@ fun SessionScreen(
::openImage,
)
},
isWhole = { it in shownWholeCalls },
onShowAll = { capped ->
toggleAnchored(row) {
shownWholeCalls = shownWholeCalls + capped
}
},
)
is TranscriptRow.Single ->
when (val item = row.item) {
@@ -1621,6 +1658,16 @@ fun SessionScreen(
::openImage,
)
},
isWhole = { part ->
Capped(item.id, part) in shownWholeCalls
},
onShowAll = { part ->
toggleAnchored(row) {
shownWholeCalls =
shownWholeCalls +
Capped(item.id, part)
}
},
)
is TranscriptItem.QuestionCard ->
QuestionRow(item, ::answerAll)
@@ -0,0 +1,108 @@
package com.example.aiapp
/**
* How much of a long thing the transcript draws before offering the rest behind a tap.
*
* One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept
* in one file because four copies would eventually disagree about what "too long" is -- and because
* the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the
* other half of this). The two are deliberately identical so that a benchmark comparing the apps is
* comparing renderers rather than policies.
*
* **Lines and bytes both, whichever runs out first**, because they run out on different things: a
* diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap
* counting only one of them draws the whole of the other.
*
* **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line
* saying what went wrong is nearly always the first; a message is read from the top for the obvious
* reason. (A path is identified by its other end -- none of these is a path.)
*/
object TextCap {
/**
* The bound on a verbatim block -- a tool call's input or its output. Short, because this text
* is a machine's and the reader is looking for one line of it.
*/
const val VERBATIM_LINES = 80
const val VERBATIM_BYTES = 4096
/**
* The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose
* is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log,
* and cutting a reply at 80 lines would cut most long answers that nobody would call long.
*/
const val MESSAGE_LINES = 200
const val MESSAGE_BYTES = 16 * 1024
}
/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */
data class CutText(
/** What to draw. */
val shown: String,
/**
* The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines"
* offer says, and a reader deciding whether to ask for the rest wants to know how much the rest
* is.
*/
val lines: Int,
)
/**
* [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits.
*
* Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the
* same place. UTF-8 is what the wire carries and what `client-core` measures.
*/
fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? {
require(maxLines > 0 && maxBytes > 0) {
"a cap of nothing shows an empty block and a 'Show all' for every value there is"
}
val bytes = text.toByteArray(Charsets.UTF_8)
var byLines = -1
var seen = 0
for (i in text.indices) {
if (text[i] == '\n') {
seen++
if (seen == maxLines) {
byLines = i
break
}
}
}
val byBytes =
if (bytes.size > maxBytes) {
// Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on
// one would split a character in half and `String(bytes)` would draw a replacement mark
// where an em dash was.
var end = maxBytes
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
String(bytes, 0, end, Charsets.UTF_8).length
} else {
-1
}
val cut =
when {
byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes)
byLines >= 0 -> byLines
byBytes >= 0 -> byBytes
else -> return null
}
return CutText(text.take(cut), lineCount(text))
}
/**
* How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing
* newline ends the last line rather than starting an empty one.
*
* Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the
* two apps have to offer "Show all N lines" with the same N for the same message, and a count that
* is one out on every text ending in a newline (which is most tool output) would show it.
*/
fun lineCount(text: String): Int =
when {
text.isEmpty() -> 0
text.endsWith("\n") -> text.count { it == '\n' }
else -> text.count { it == '\n' } + 1
}
/** What a "Show all" offer says, so the wording is one string rather than one per surface. */
fun showAllLabel(lines: Int): String = "Show all $lines lines"
@@ -1,5 +1,6 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -9,6 +10,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.json.JSONObject
@@ -105,33 +108,112 @@ fun parseToolInput(tool: String, input: String): ToolInput {
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
*
* **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the
* largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] /
* [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the
* subject and the leftover fields are two halves of the same answer to "what was this call given",
* and two would make the reader ask twice. [whole] is the reader having already asked.
*/
@Composable
fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) {
fun ToolInputView(
tool: String,
input: String,
modifier: Modifier = Modifier,
whole: Boolean = false,
onShowAll: () -> Unit = {},
) {
val parsed = remember(tool, input) { parseToolInput(tool, input) }
if (parsed.subject == null && parsed.rest.isEmpty()) return
val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) }
val rest =
remember(parsed.rest, whole) {
capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole)
}
RawBlock(modifier) {
parsed.subject?.let { subject ->
subject.shown?.let { shown ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
Text(
// Highlighted over what is *drawn* rather than over the whole subject, so a cut
// cannot leave a span pointing past the end of the text it styles.
//
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(subject, parsed.language) { highlight(subject, parsed.language) },
remember(shown, parsed.language) { highlight(shown, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
}
parsed.rest.forEach {
rest.shown?.let { shown ->
// Never dropped: a field left out would be claiming the tool has no other input when it
// might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text
// like
// tool results I think it should not be wrapped".
Text(
it,
shown,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
softWrap = false,
modifier =
Modifier.padding(top = 2.dp)
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
)
}
// The count is the whole input's, both blocks together, because that is what the one
// control reveals.
if (subject.cut || rest.cut) {
ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll)
}
}
}
/** One verbatim block as it will be drawn; see [capped]. */
data class CappedBlock(
/** The text to draw, or `null` when there was none to begin with. */
val shown: String?,
/** The line count of the whole of it. */
val lines: Int,
/** Whether anything was left out. */
val cut: Boolean,
)
/**
* [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise.
*
* `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with
* something.
*/
private fun capped(text: String?, whole: Boolean): CappedBlock {
if (text == null) return CappedBlock(null, 0, false)
val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
return when (cut) {
null -> CappedBlock(text, lineCount(text), false)
else -> CappedBlock(cut.shown, cut.lines, true)
}
}
/**
* The "Show all N lines" under a capped block.
*
* It says the count rather than "more" because the reader is deciding whether to ask for it: "Show
* all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not
* at all.
*/
@Composable
fun ShowAllRow(lines: Int, onClick: () -> Unit) {
val label = showAllLabel(lines)
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics {
contentDescription = label
},
)
}
@@ -2,6 +2,7 @@ package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card
@@ -163,6 +165,9 @@ fun ToolGroup(
onToolToggle: (String) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit,
/** Which capped blocks the reader has asked to see whole; see [Capped]. */
isWhole: (Capped) -> Boolean = { false },
onShowAll: (Capped) -> Unit = {},
) {
val heading = "Called ${group.calls.size} tools"
if (!expanded) {
@@ -202,6 +207,8 @@ fun ToolGroup(
onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer,
image = image,
isWhole = { part -> isWhole(Capped(call.id, part)) },
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
shape = connectedShape(index, group.calls.size),
)
}
@@ -272,6 +279,21 @@ private val GROUP_INSET = 4.dp
/** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp
/**
* Which half of an open card a cap and its "Show all" belong to.
*
* The two are capped and revealed independently: opening the whole of a call's input says nothing
* about wanting the whole of its output, and one control revealing both would make the card jump by
* the sum of two things when it was asked about one.
*/
enum class ToolPart {
INPUT,
OUTPUT,
}
/** One capped thing on the screen that the reader may ask to see whole. */
data class Capped(val call: String, val part: ToolPart)
/**
* One tool call.
*
@@ -291,6 +313,11 @@ fun ToolCard(
onToggle: () -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit = {},
/**
* Whether the reader has asked for the whole of this call's input or output; see [ToolPart].
*/
isWhole: (ToolPart) -> Boolean = { false },
onShowAll: (ToolPart) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape,
) {
@@ -353,11 +380,28 @@ fun ToolCard(
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
ToolInputView(
tool.tool,
tool.input,
Modifier.padding(top = 4.dp),
whole = isWhole(ToolPart.INPUT),
onShowAll = { onShowAll(ToolPart.INPUT) },
)
}
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
// Capped like the input, and revealed separately from it: a reader who wants
// the whole of a 900-line `new_string` rarely also wants the whole of the build
// log underneath it.
val wholeOutput = isWhole(ToolPart.OUTPUT)
val cut =
remember(tool.output, wholeOutput) {
if (wholeOutput) null
else
cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
}
val shown = cut?.shown ?: tool.output
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried
@@ -367,13 +411,23 @@ fun ToolCard(
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) }
val styled = remember(shown, palette) { ansiStyled(shown, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
// Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw'
// text like tool results I think it should not be wrapped". Wrapping a
// column-aligned log is what destroys the alignment that carried its
// meaning, one line at a time and only on the long lines.
Text(
styled,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier =
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (cut != null) {
ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) }
}
}
}
}
@@ -130,6 +130,26 @@ sealed class TranscriptUnit {
get() = "u$seq:$ordinal"
}
/**
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
*
* Its own unit rather than something inside the row above it, because the row above it is a
* *bounded* item now and this is what says so -- and because a control that lives inside the
* thing it reveals moves the moment it is pressed.
*/
data class ShowAll(
override val seq: Long,
override val ordinal: Int,
/** The row this belongs to; what goes into the set of rows shown whole. */
val row: Any,
/** The line count of the whole message, which is what the offer says. */
val lines: Int,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "s$row"
}
/** One memory note of a settled reply; see [MemoryNote]. */
data class Memory(
override val seq: Long,
@@ -142,6 +162,36 @@ sealed class TranscriptUnit {
}
}
/**
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
*
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
* untouched by a reader opening or closing one.
*
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
* hundred lines while the model was plainly still writing would read as the stream having died.
* `iris`'s `row::build_row` states the same rule for the same reason.
*/
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
if (row.key in shownWhole) return row to null
val cut =
when {
item is TranscriptItem.UserMsg ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
item is TranscriptItem.AssistantMsg && item.settled ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
else -> null
} ?: return row to null
return cut.second to cut.first.lines
}
/**
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
@@ -161,11 +211,14 @@ fun transcriptUnits(
rows: List<TranscriptRow>,
replies: ParsedReplies,
openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> {
val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, row ->
rows.forEachIndexed { index, whole ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val (row, hidden) = capRow(whole, shownWhole)
val rowStart = units.size
val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes
@@ -240,6 +293,16 @@ fun transcriptUnits(
} else {
units += TranscriptUnit.Whole(row, rowGap)
}
if (hidden != null) {
units +=
TranscriptUnit.ShowAll(
row.startSeq,
units.size - rowStart,
row.key,
hidden,
BLOCK_SPACING,
)
}
}
units.reverse()
reportDuplicateKeys(units)
@@ -266,11 +329,18 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
fun unwarmedReplies(rows: List<TranscriptRow>, replies: ParsedReplies): List<TranscriptItem> =
rows.mapIndexedNotNull { index, row ->
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
fun unwarmedReplies(
rows: List<TranscriptRow>,
replies: ParsedReplies,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
// The *capped* row's text, since that is what the flatten will draw and so what has to be
// ready: a capped row draws its head, which is a different string from the message and so a
// different cache entry.
val row = capRow(whole, shownWhole).first
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
/**
* Above this many characters, a user message is drawn in slices rather than as one bubble.
@@ -375,6 +445,7 @@ private val TranscriptUnit?.kind: String
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ShowAll -> "show all"
is TranscriptUnit.Whole ->
when (val row = row) {
is TranscriptRow.Tools -> "tool group"
@@ -0,0 +1,63 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The cap rule, which this app and the Rust one have to answer identically -- these mirror
* `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing
* in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark
* comparing the two would report as a rendering difference.
*/
class TextCapTest {
@Test
fun textUnderBothBoundsIsNotCut() {
assertNull(cutText("one\ntwo\nthree", 80, 4096))
}
@Test
fun theLineBoundCutsAtALineBoundary() {
val cut = cutText("a\nb\nc\nd\n", 2, 4096)!!
assertEquals("a\nb", cut.shown)
assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's")
}
/**
* The half the line bound cannot catch: one enormous line, which is what a minified file is.
*/
@Test
fun theByteBoundCutsOneLongLine() {
val cut = cutText("x".repeat(5000), 80, 4096)!!
assertEquals(4096, cut.shown.length)
assertEquals(1, cut.lines)
}
@Test
fun theTighterOfTheTwoBoundsWins() {
val text = "aaaa\n".repeat(100)
assertEquals(100, cutText(text, 80, 100)!!.shown.length)
assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown)
}
/**
* A cut landing inside a multi-byte character has to back up to the boundary. The Rust half
* measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut
* the same text at a different place in every message with an em dash in it.
*/
@Test
fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() {
val cut = cutText("é".repeat(100), 80, 11)!!
assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut")
}
/** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */
@Test
fun lineCountMatchesRustsStrLines() {
assertEquals(0, lineCount(""))
assertEquals(1, lineCount("a"))
assertEquals(1, lineCount("a\n"))
assertEquals(2, lineCount("a\nb"))
assertEquals(2, lineCount("a\nb\n"))
}
}