package com.example.aiapp import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier /** * How much of a reply is drawn before the reader is offered the rest. * * A limit on the *source*, not on the height, and that is the whole point. Clipping a laid-out row * to a height saves nothing: Compose measures the text and then throws the overflow away, so the * line-breaking has already happened. Cutting the string before it is parsed is what stops the work * from being done -- and it stops the parse too, which the height version could never reach. * * Four thousand characters is about two screenfuls of body text on a phone. Two rather than one so * that a reply somewhat over the limit is not cut nearly in half, and so a reader who does not * press anything still gets more than they can see at once. * * The measurement that made this worth having, from the ai-app-2 session on 2026-08-30: one message * in a real transcript is over 14,000px tall -- seven screens -- and a single frame spent 59.6ms in * `measureAndLayout` when it entered the viewport. That cost is proportional to the whole row * however little of it is on screen, and it is paid again every time the row comes back. */ private const val REPLY_CAP_CHARS = 4000 /** * How far past the cap a reply has to be before it is worth cutting. * * Without this a message of 4,001 characters loses one character and gains a button, which is worth * nothing to anybody and costs a control that has to be read and decided about. The row that needs * this treatment is several times the limit, not just over it. */ private const val REPLY_CAP_SLACK = 1000 /** * The opening of [text] if it is long enough to be worth cutting, or null if it should be drawn * whole. * * Cut at a line ending, because a markdown source cut mid-line is a different document: half a * heading marker, a list item with no bullet, a link whose closing bracket is in the part that was * dropped. A whole number of lines is the coarsest cut that cannot invent syntax. * * A fence left open by the cut is closed, which is the one case a line boundary does not save. An * unterminated ``` swallows the rest of the reply into a code block, so the truncation would change * how the part still on screen is *drawn* rather than only how much of it there is -- and a reader * has no way to tell that from the reply genuinely having been code. */ fun shortenedReply(text: String, limit: Int = REPLY_CAP_CHARS): String? { if (text.length <= limit + REPLY_CAP_SLACK) return null val cut = text.lastIndexOf('\n', limit).let { if (it <= 0) limit else it } val head = text.substring(0, cut) // Fences are counted rather than matched: an opening and a closing one are the same token, so // an odd number of them is an opening that never closed. return if (head.split("\n").count { it.trimStart().startsWith("```") } % 2 == 1) "$head\n```" else head } /** * A reply drawn to [REPLY_CAP_CHARS], with the rest a press away. * * The control says how much is behind it rather than only "Show more", because the two answers a * reader wants are different sizes of decision: another paragraph is worth opening while standing * in a scroll, and another twenty screens is worth knowing about first. * * Expanded is remembered by the screen rather than by this row, so scrolling away and back does not * shut something the reader deliberately opened -- see SessionScreen's other expansion sets. */ @Composable fun CappedReply( full: String, shortened: String, replies: ParsedReplies, onShowMore: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier.fillMaxWidth()) { AssistantMessage(shortened, replies) TextButton(onClick = onShowMore) { Text( "Show the rest (${remaining(full.length - shortened.length)})", style = MaterialTheme.typography.labelLarge, ) } } } /** What is left, in the units a reader thinks in rather than in characters. */ private fun remaining(chars: Int): String { // Against the same figure the cap is written in, so the two cannot drift: a "screenful" here is // whatever [REPLY_CAP_CHARS] is two of. val screens = chars.toDouble() / (REPLY_CAP_CHARS / 2) return when { screens < 1.5 -> "about another screen" screens < 20 -> "about ${Math.round(screens)} more screens" else -> "more than 20 screens" } }