git.iko.soy/opencode-android
on a0ce9ce help sign in
855 lines 44 kB history raw
1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.os.Build
7import android.widget.Toast
8import androidx.compose.animation.core.RepeatMode
9import androidx.compose.animation.core.animateFloat
10import androidx.compose.animation.core.infiniteRepeatable
11import androidx.compose.animation.core.rememberInfiniteTransition
12import androidx.compose.animation.core.tween
13import androidx.compose.foundation.background
14import androidx.compose.foundation.gestures.detectTapGestures
15import androidx.compose.foundation.horizontalScroll
16import androidx.compose.foundation.layout.Arrangement
17import androidx.compose.foundation.layout.Box
18import androidx.compose.foundation.layout.Column
19import androidx.compose.foundation.layout.Row
20import androidx.compose.foundation.layout.defaultMinSize
21import androidx.compose.foundation.layout.fillMaxWidth
22import androidx.compose.foundation.layout.padding
23import androidx.compose.foundation.layout.size
24import androidx.compose.foundation.rememberScrollState
25import androidx.compose.foundation.text.selection.SelectionContainer
26import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.automirrored.filled.WrapText
28import androidx.compose.material.icons.filled.Check
29import androidx.compose.material.icons.filled.ContentCopy
30import androidx.compose.material.icons.filled.ExpandLess
31import androidx.compose.material.icons.filled.ExpandMore
32import androidx.compose.material3.AlertDialog
33import androidx.compose.material3.Icon
34import androidx.compose.material3.IconButton
35import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.Surface
37import androidx.compose.material3.Text
38import androidx.compose.material3.TextButton
39import androidx.compose.material3.Typography
40import androidx.compose.runtime.Composable
41import androidx.compose.runtime.CompositionLocalProvider
42import androidx.compose.runtime.LaunchedEffect
43import androidx.compose.runtime.getValue
44import androidx.compose.runtime.mutableStateOf
45import androidx.compose.runtime.remember
46import androidx.compose.runtime.rememberUpdatedState
47import androidx.compose.runtime.saveable.rememberSaveable
48import androidx.compose.runtime.setValue
49import androidx.compose.runtime.snapshotFlow
50import androidx.compose.ui.Alignment
51import androidx.compose.ui.Modifier
52import androidx.compose.ui.draw.clip
53import androidx.compose.ui.graphics.Color
54import androidx.compose.ui.hapticfeedback.HapticFeedbackType
55import androidx.compose.ui.input.pointer.pointerInput
56import androidx.compose.ui.platform.LocalContext
57import androidx.compose.ui.platform.LocalHapticFeedback
58import androidx.compose.ui.platform.LocalUriHandler
59import androidx.compose.ui.platform.UriHandler
60import androidx.compose.ui.res.pluralStringResource
61import androidx.compose.ui.res.stringResource
62import androidx.compose.ui.semantics.LiveRegionMode
63import androidx.compose.ui.semantics.invisibleToUser
64import androidx.compose.ui.semantics.liveRegion
65import androidx.compose.ui.semantics.semantics
66import androidx.compose.ui.text.AnnotatedString
67import androidx.compose.ui.text.SpanStyle
68import androidx.compose.ui.text.TextLayoutResult
69import androidx.compose.ui.text.TextStyle
70import androidx.compose.ui.text.buildAnnotatedString
71import androidx.compose.ui.text.font.FontFamily
72import androidx.compose.ui.unit.dp
73import kotlinx.coroutines.flow.conflate
74import kotlinx.coroutines.delay
75import com.mikepenz.markdown.annotator.AnnotatorSettings
76import com.mikepenz.markdown.annotator.DefaultAnnotatorSettings
77import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
78import com.mikepenz.markdown.compose.LocalMarkdownAnnotator
79import com.mikepenz.markdown.compose.LocalMarkdownColors
80import com.mikepenz.markdown.compose.LocalMarkdownTypography
81import com.mikepenz.markdown.compose.components.MarkdownComponentModel
82import com.mikepenz.markdown.compose.components.markdownComponents
83import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownText
84import com.mikepenz.markdown.m3.Markdown
85import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
86import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig
87import com.mikepenz.markdown.model.MarkdownState
88import com.mikepenz.markdown.model.rememberMarkdownState
89import org.intellij.markdown.MarkdownElementTypes
90import org.intellij.markdown.ast.ASTNode
91import org.intellij.markdown.ast.getTextInNode
92import soy.iko.opencode.R
93import soy.iko.opencode.data.model.FilePart
94import soy.iko.opencode.data.network.NetworkConfig
96/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
97 * annotator wrapper, so [ParagraphWithInlineCodeCopy]'s `pointerInput` can resolve a long-press
98 * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
99 * for link-tap hit-testing. */
100private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
102/**
103 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
104 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
105 * Code blocks/fences get an inline copy button.
106 *
107 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
108 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
109 * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
110 * copies all TextParts; this complements it with partial selection.
111 *
112 * During streaming, the full markdown is re-parsed on every token (the library re-parses
113 * whenever the content string changes). To avoid O(n²) work during long responses, the
114 * rendered content is throttled — the latest [markdown] is committed to the renderer at
115 * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
116 *
117 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
118 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
119 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
120 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
121 * starving the render and showing stale content until the stream pauses. The conflated
122 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
123 * so rendering always progresses even under continuous fast streaming.
124 *
125 * When [streaming] is false (the common case: every non-active message in the chat list),
126 * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
127 * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
128 */
129@Composable
130fun MarkdownText(
131 markdown: String,
132 modifier: Modifier = Modifier,
133 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
134 streaming: Boolean = false,
135 imageContext: ImageLoadContext? = null,
136) {
137 val scale = LocalChatTextScale.current
138 if (scale == 1f) {
139 MarkdownBody(markdown, modifier, streaming, imageContext)
140 } else {
141 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
142 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
143 // the renderer derives its typography from MaterialTheme, so this reaches every
144 // element without plumbing a scale through the library. colorScheme/shapes default
145 // to the current theme's, so only sizes change. Memoized so a recomposition that
146 // doesn't change the scale doesn't rebuild the 15-role Typography.
147 val base = MaterialTheme.typography
148 val scaled = remember(scale, base) { base.scaledBy(scale) }
149 MaterialTheme(typography = scaled) {
150 MarkdownBody(markdown, modifier, streaming, imageContext)
151 }
152 }
155@Composable
156private fun MarkdownBody(
157 markdown: String,
158 modifier: Modifier = Modifier,
159 streaming: Boolean = false,
160 imageContext: ImageLoadContext? = null,
161) {
162 // Provide a custom image component when an ImageLoadContext is available so markdown
163 // image tags (![alt](url)) route through RemoteImage (Basic auth + same-origin guard).
164 // Without this the library's default image loader fetches with no auth and fails on
165 // server-relative URLs. When no context is available, fall back to the library default
166 // by not overriding the image component.
167 // The `paragraph` component is overridden with [ParagraphWithInlineCodeCopy] so a long-press
168 // on a single-backtick inline code span copies its text — matching the copy affordance the
169 // fenced/indented code blocks already have via [CodeWithCopy].
170 val components = if (imageContext != null) {
171 // Remember on imageContext so a recomposition that doesn't change the context (the
172 // common case during streaming/scroll) doesn't rebuild the 21-field components
173 // container + 4 lambdas. The lambdas capture `imageContext` (for the image component)
174 // and the CodeWithCopy/ParagraphWithInlineCodeCopy factories (which only capture the
175 // stable MarkdownComponentModel passed by the library at invoke time), so keying on
176 // imageContext is sufficient for stability.
177 remember(imageContext) {
178 markdownComponents(
179 codeFence = { CodeWithCopy(it) },
180 codeBlock = { CodeWithCopy(it) },
181 paragraph = { ParagraphWithInlineCodeCopy(it) },
182 image = { MarkdownImage(it, imageContext) },
183 )
184 }
185 } else {
186 remember {
187 markdownComponents(
188 codeFence = { CodeWithCopy(it) },
189 codeBlock = { CodeWithCopy(it) },
190 paragraph = { ParagraphWithInlineCodeCopy(it) },
191 )
192 }
193 }
194 val context = LocalContext.current
195 // Intercept link taps so the scheme can be validated before the library fires an
196 // implicit ACTION_VIEW. A hallucinated javascript:/file:/intent: URL must not launch
197 // an unexpected intent chooser. Taps are routed to a dialog offering Open (only for
198 // http/https/mailto) and Copy link. The default handler is captured here and invoked
199 // from the dialog's Open action.
200 val defaultUriHandler = LocalUriHandler.current
201 var pendingLink by remember { mutableStateOf<String?>(null) }
202 val safeUriHandler = remember {
203 object : UriHandler {
204 override fun openUri(uri: String) {
205 pendingLink = uri
206 }
207 }
208 }
209 // In-conversation search highlighting: when LocalSearchHighlight carries a query,
210 // wrap the default MarkdownAnnotator to add a translucent background span over each
211 // case-insensitive match in the rendered text. Skipped while streaming so the
212 // throttled re-render isn't invalidated on every token. Offsets are derived from the
213 // builder's actual rendered output (not the raw markdown), so highlights land on the
214 // right characters even inside formatted spans.
215 val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
216 val highlightColor = MaterialTheme.colorScheme.secondaryContainer
217 val baseAnnotator = LocalMarkdownAnnotator.current
218 // Capture the inline-code typography/colors in the composable scope (the annotator lambda is
219 // NOT a @Composable scope, so it can't read LocalMarkdownTypography.current itself). The
220 // library's buildMarkdownAnnotatedString reads these in its own @Composable body for the
221 // default CODE_SPAN branch; since we intercept CODE_SPAN in the annotator, we capture them
222 // here and close over them in appendInlineCodeSpan.
223 val inlineCodeTypography = LocalMarkdownTypography.current
224 val inlineCodeColors = LocalMarkdownColors.current
225 // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
226 // CODE_SPAN range so [ParagraphWithInlineCodeCopy] can resolve a long-press offset to the
227 // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
228 // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
229 // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
230 // rather than extracted to a helper to keep this file under detekt's TooManyFunctions threshold.
231 val inlineCodeAnnotator = remember(baseAnnotator, inlineCodeTypography, inlineCodeColors) {
232 val codeStyle = inlineCodeTypography.inlineCode.copy(
233 color = inlineCodeTypography.inlineCode.color,
234 background = inlineCodeColors.inlineCodeBackground,
235 ).toSpanStyle()
236 DefaultMarkdownAnnotator(
237 annotate = { content, node ->
238 if (node.type == MarkdownElementTypes.CODE_SPAN) {
239 pushStyle(codeStyle)
240 append(' ')
241 val codeStart = this.length
242 // Drop the opening/closing backtick children (matching the library's internal
243 // innerList(), which is inaccessible from here).
244 val codeText = StringBuilder()
245 for (child in node.children) {
246 if (child.type == org.intellij.markdown.MarkdownTokenTypes.BACKTICK) continue
247 val text = child.getTextInNode(content).toString()
248 codeText.append(text)
249 append(text)
250 }
251 val codeEnd = this.length
252 if (codeEnd > codeStart) {
253 pushStringAnnotation(MARKDOWN_TAG_INLINE_CODE, codeText.toString())
254 pop()
255 }
256 append(' ')
257 pop()
258 true
259 } else {
260 baseAnnotator.annotate?.invoke(this, content, node) ?: false
261 }
262 },
263 config = DefaultMarkdownAnnotatorConfig(),
264 )
265 }
266 // Compose the inline-code tagger with the search-highlight wrapper when a query is active.
267 // The highlight wrapper runs on top, so it sees the final (tagged) builder output and can
268 // highlight matches inside inline code too.
269 val effectiveAnnotator = if (searchQuery != null && !streaming) {
270 remember(inlineCodeAnnotator, searchQuery, highlightColor) {
271 val inner = inlineCodeAnnotator.annotate
272 DefaultMarkdownAnnotator(
273 annotate = { text, node ->
274 val startLen = this.length
275 val handled = inner?.invoke(this, text, node) ?: false
276 val endLen = this.length
277 if (endLen > startLen) {
278 addSearchHighlights(
279 this,
280 this.toString().substring(startLen, endLen),
281 startLen,
282 searchQuery,
283 highlightColor,
284 )
285 }
286 handled
287 },
288 config = DefaultMarkdownAnnotatorConfig(),
289 )
290 }
291 } else {
292 inlineCodeAnnotator
293 }
294 if (streaming) {
295 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
296 val markdownState = rememberUpdatedState(markdown)
297 var renderedContent by remember { mutableStateOf(markdown) }
298 // A SINGLE long-lived effect (keyed on Unit), per this file's design. Keying on the
299 // content — even a prefix like markdown.take(32) — cancels and restarts the effect as the
300 // first characters stream in (the prefix changes on nearly every early token), so the
301 // in-flight delay never completes and the throttle is defeated at the start of every
302 // response. A streaming MarkdownBody instance only ever grows its content (a different
303 // message is a fresh composition with its own remember), so no reset key is needed.
304 // conflate() coalesces a burst of token updates into a single emission so the collector
305 // sees only the latest value after the previous delay completes. Plain collect (not
306 // collectLatest) is critical: collectLatest would cancel the delay on every new value,
307 // reintroducing the same starvation a keyed effect has.
308 LaunchedEffect(Unit) {
309 snapshotFlow { markdownState.value }
310 .conflate()
311 .collect { md ->
312 // Only throttle when the content is growing incrementally (streaming): a
313 // shorter switch or initial render proceeds immediately. Checking length is
314 // O(1) vs. startsWith which is O(n) in the rendered content length — during a
315 // long streaming response this runs every throttle cycle. Within one streaming
316 // composition, growing length means appending (streaming), not a rewrite.
317 if (renderedContent.isNotEmpty() &&
318 md.length > renderedContent.length
319 ) {
320 delay(NetworkConfig.streamingThrottleMs)
321 }
322 renderedContent = md
323 }
324 }
325 // SelectionContainer is intentionally omitted during streaming: the content is
326 // changing every ~50ms, and an active selection would be invalidated (and the
327 // selection handles would flicker) on each throttle commit. Partial selection
328 // becomes available once the stream finishes; the per-message Copy button in
329 // MessageBubble copies all TextParts at any time.
330 // A polite live region announces streaming content to TalkBack users. The throttle
331 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
332 // interrupting on every token; the reader hears the reply grow in measured chunks.
333 //
334 // retainState = true is critical: without it the library flips the internal state
335 // to Loading (an empty Box) on every content change, then back to Success once the
336 // off-main parse completes. During streaming the throttle commits ~20x/sec, so the
337 // whole rendered subtree would blink out and be rebuilt on every commit — visible
338 // flicker, full recomposition of every paragraph/code block, and layout thrash as
339 // the bubble height collapses then re-measures. retainState keeps the previous
340 // Success visible while the new content parses, so the swap is Success→Success
341 // (no empty-box frame). The library's own rememberMarkdownState already uses a
342 // single LaunchedEffect(Unit) + snapshotFlow + conflate to feed updates without
343 // re-launching, so this composes cleanly with the throttle above.
344 CompositionLocalProvider(
345 LocalUriHandler provides safeUriHandler,
346 ) {
347 val markdownState: MarkdownState = rememberMarkdownState(
348 content = renderedContent,
349 retainState = true,
350 )
351 Markdown(
352 markdownState = markdownState,
353 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
354 components = components,
355 annotator = effectiveAnnotator,
356 )
357 }
358 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
359 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
360 // element so the markdown AST isn't re-parsed to toggle it.
361 if (!LocalReducedMotion.current) StreamingCaret()
362 } else {
363 // retainState = true avoids a one-frame Loading flash when the content changes
364 // (e.g. an edit re-render, or a theme switch re-resolving colors). For static
365 // content the first parse is fast and the Success is shown directly; retainState
366 // only matters on subsequent content changes, where it keeps the old Success
367 // visible until the new one is ready instead of flashing an empty Box.
368 val content: @Composable () -> Unit = {
369 SelectionContainer {
370 val markdownState: MarkdownState = rememberMarkdownState(
371 content = markdown,
372 retainState = true,
373 )
374 Markdown(
375 markdownState = markdownState,
376 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
377 components = components,
378 annotator = effectiveAnnotator,
379 )
380 }
381 }
382 CompositionLocalProvider(
383 LocalUriHandler provides safeUriHandler,
384 ) {
385 content()
386 }
387 }
388 // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
389 // schemes the app is willing to hand to the system (http/https/mailto); for anything
390 // else, copy is the only escape so a model hallucination can't fire an unexpected intent.
391 pendingLink?.let { url ->
392 LinkActionDialog(
393 url = url,
394 canOpen = isSafeLinkScheme(url),
395 onDismiss = { pendingLink = null },
396 onOpen = {
397 pendingLink = null
398 runCatching { defaultUriHandler.openUri(url) }
399 },
400 onCopy = {
401 pendingLink = null
402 copyToClipboard(context, context.getString(R.string.copy_link), url)
403 },
404 )
405 }
408/**
409 * Adds a translucent background span over each case-insensitive occurrence of [query] found
410 * in [chunk], mapping the chunk-relative offsets into the builder via [baseOffset]. Called
411 * from the search-highlight annotator wrapper; [chunk] is the text the default annotator
412 * appended to the builder for a single markdown node, so the offsets line up with what the
413 * user actually sees (markdown syntax already stripped).
414 */
415private fun addSearchHighlights(
416 builder: AnnotatedString.Builder,
417 chunk: String,
418 baseOffset: Int,
419 query: String,
420 color: Color,
421) {
422 val q = query.trim()
423 if (q.isEmpty()) return
424 var idx = chunk.indexOf(q, ignoreCase = true)
425 while (idx >= 0) {
426 builder.addStyle(
427 SpanStyle(background = color),
428 baseOffset + idx,
429 baseOffset + idx + q.length,
430 )
431 idx = chunk.indexOf(q, idx + q.length, ignoreCase = true)
432 }
435/** Schemes the app is willing to forward to the system's URI handler from a markdown link. */
436private fun isSafeLinkScheme(uri: String): Boolean {
437 val scheme = uri.substringBefore(':', "").lowercase()
438 return scheme == "http" || scheme == "https" || scheme == "mailto"
441/**
442 * Dialog shown when the user taps a markdown link. For http/https/mailto URIs, offers Open
443 * (via the platform default handler) and Copy link. For any other scheme, only Copy is
444 * offered (with an "unsupported link type" note) so a hallucinated javascript:/file: URL
445 * can't launch an unexpected intent.
446 */
447@Composable
448private fun LinkActionDialog(
449 url: String,
450 canOpen: Boolean,
451 onDismiss: () -> Unit,
452 onOpen: () -> Unit,
453 onCopy: () -> Unit,
454) {
455 AlertDialog(
456 onDismissRequest = onDismiss,
457 title = {
458 Text(
459 text = url,
460 style = MaterialTheme.typography.bodySmall,
461 color = MaterialTheme.colorScheme.onSurfaceVariant,
462 )
463 },
464 text = {
465 if (!canOpen) {
466 Text(
467 stringResource(R.string.link_unsupported),
468 style = MaterialTheme.typography.bodyMedium,
469 color = MaterialTheme.colorScheme.error,
470 )
471 }
472 },
473 confirmButton = {
474 androidx.compose.foundation.layout.Row {
475 TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_link)) }
476 if (canOpen) {
477 TextButton(onClick = onOpen) { Text(stringResource(R.string.open_link)) }
478 }
479 }
480 },
481 dismissButton = {
482 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
483 },
484 )
487/**
488 * Custom markdown `paragraph` component that adds a long-press-to-copy affordance for inline
489 * code spans (single-backtick `` `code` ``). The library renders CODE_SPAN inline as a styled
490 * run within the paragraph's [AnnotatedString]; there's no discrete `inlineCode` component to
491 * override. Instead, [MarkdownBody] installs an annotator that stamps each CODE_SPAN range with
492 * a [MARKDOWN_TAG_INLINE_CODE] string annotation; this component builds the paragraph's
493 * [AnnotatedString] via the library's [buildMarkdownAnnotatedString] (so the annotator runs and
494 * the tags land), then renders it with a `pointerInput` that hit-tests a long-press against the
495 * tagged ranges and copies the span's text on hit — mirroring the copy affordance fenced/indented
496 * code blocks get via [CodeWithCopy], for the common case of short inline commands/identifiers.
497 * A long-press inside a [SelectionContainer] is consumed by the selection machinery first, so
498 * the copy fires from non-selectable contexts (e.g. while streaming, where no SelectionContainer
499 * wraps the text); when selection is active the user can still copy via the selection handles.
500 * Haptic feedback matches every other copy affordance.
501 */
502@Composable
503private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
504 val context = LocalContext.current
505 val haptics = LocalHapticFeedback.current
506 val style = model.typography.paragraph
507 val typography = LocalMarkdownTypography.current
508 val annotator = LocalMarkdownAnnotator.current
509 // buildMarkdownAnnotatedString needs an explicit AnnotatorSettings in 0.43+ (the old
510 // overload that read LocalMarkdownAnnotator internally was removed). Build one from the
511 // current composition locals so the inline-code tagger installed in MarkdownBody still
512 // runs. The library's own MarkdownParagraph does the same.
513 val settings = DefaultAnnotatorSettings(
514 linkTextSpanStyle = typography.textLink,
515 codeSpanStyle = typography.inlineCode.toSpanStyle(),
516 annotator = annotator,
517 )
518 // Memoize the annotated string on its inputs so a recomposition that doesn't change
519 // the paragraph's content/AST/style (the common case — e.g. a sibling paragraph
520 // recomposing, a theme color resolving to the same values) doesn't re-run the
521 // library's inline-markdown annotator pass (regex/string scanning + span allocation).
522 // During streaming, the parent's retainState=true means only the changed paragraphs
523 // recompose, so this caps re-annotation to genuinely-changed paragraphs.
524 val styledText = remember(model.content, model.node, settings, style) {
525 buildAnnotatedString {
526 pushStyle(style.toSpanStyle())
527 buildMarkdownAnnotatedString(model.content, model.node, settings)
528 pop()
529 }
530 }
531 // Capture the layout result so a long-press position can be mapped to a character offset,
532 // then to a tagged range — the same hit-testing pattern the library uses for link taps.
533 var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
534 LibraryMarkdownText(
535 content = styledText,
536 node = model.node,
537 modifier = Modifier.pointerInput(styledText) {
538 detectTapGestures(
539 onLongPress = { pos ->
540 val result = layoutResult ?: return@detectTapGestures
541 val offset = result.getOffsetForPosition(pos)
542 val span = styledText.getStringAnnotations(MARKDOWN_TAG_INLINE_CODE, offset, offset)
543 .firstOrNull()
544 ?: return@detectTapGestures
545 val code = span.item
546 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
547 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
548 },
549 )
550 },
551 style = style,
552 onTextLayout = { result, _ -> layoutResult = result },
553 )
556/**
557 * Renders a fenced or indented code block with a header toolbar: the fence's language
558 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
559 * copy button. Self-contained so it doesn't depend on the library's internal code-block
560 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
561 * be flipped per-block.
562 *
563 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on a long
564 * response), code blocks over [COLLAPSED_CODE_LINES] lines render only the head until the
565 * user expands — mirroring the collapse already applied to tool output and diffs.
566 */
567@Composable
568private fun CodeWithCopy(model: MarkdownComponentModel) {
569 val context = LocalContext.current
570 // Key on the content string + the AST node's offset range (stable ints) instead of
571 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
572 // ASTNode), so remembering on it re-executes extract* on every recomposition.
573 val node = model.node
574 val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
575 val raw = remember(model.content, node.startOffset, node.endOffset) {
576 node.getTextInNode(model.content).toString()
577 }
578 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
579 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
580 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
581 // A long code fence can carry hundreds of lines; computing lineCount walks the string
582 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
583 // doesn't re-walk it.
584 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
585 val canCollapse = lineCount > NetworkConfig.collapsedCodeLineThreshold
586 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
587 // primarily benefits completed messages; a streaming block that crosses the cap may snap
588 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
589 var expanded by rememberSaveable(code) { mutableStateOf(false) }
590 val displayCode = if (canCollapse && !expanded) {
591 remember(code, expanded) {
592 code.lineSequence().take(NetworkConfig.collapsedCodeLineThreshold).joinToString("\n")
593 }
594 } else {
595 code
596 }
597 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
598 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
599 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
600 // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
601 // (NONE) so un-tagged fences render unchanged. Re-derived when displayCode changes so a
602 // collapsed head and the full expanded block are both highlighted correctly.
603 val palette = rememberHighlightPalette()
604 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
605 // Off-main highlighting with a cross-message LRU cache. During streaming the throttle
606 // coalesces re-highlights to ~20/sec, and the cache means a re-displayed block (scroll
607 // recycle, theme switch) is a hash hit instead of a full re-tokenize. The first frame
608 // uses a synchronous (cached or computed) result so the block doesn't flash plain-text.
609 val highlighted = rememberHighlightedCode(displayCode, syntax, palette)
610 // Default per-block wrap from the global preference; re-seed if the preference changes
611 // while this block is composed (rememberSaveable would over-persist across blocks).
612 val defaultWrap = LocalCodeWrap.current
613 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
614 // Briefly flip the copy button to a checkmark after a successful copy so the user gets
615 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
616 // the platform's tiny (and easy to miss) confirmation chip.
617 var copied by remember { mutableStateOf(false) }
618 val haptics = LocalHapticFeedback.current
619 LaunchedEffect(copied) {
620 if (copied) {
621 delay(NetworkConfig.copyFeedbackMs.toLong())
622 copied = false
623 }
624 }
625 Column(
626 modifier = Modifier
627 .fillMaxWidth()
628 .clip(MaterialTheme.shapes.small)
629 .background(MaterialTheme.colorScheme.surfaceVariant),
630 ) {
631 Row(
632 modifier = Modifier
633 .fillMaxWidth()
634 .padding(start = 12.dp, end = 2.dp),
635 verticalAlignment = Alignment.CenterVertically,
636 ) {
637 Text(
638 language ?: context.getString(R.string.code),
639 style = MaterialTheme.typography.labelSmall,
640 color = MaterialTheme.colorScheme.onSurfaceVariant,
641 modifier = Modifier.weight(1f),
642 )
643 if (canCollapse) {
644 val moreLines = lineCount - NetworkConfig.collapsedCodeLineThreshold
645 IconButton(onClick = { expanded = !expanded }) {
646 Icon(
647 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
648 contentDescription = if (expanded) context.getString(R.string.show_less)
649 else context.resources.getQuantityString(R.plurals.show_more_lines, moreLines, moreLines),
650 modifier = Modifier.size(18.dp),
651 tint = MaterialTheme.colorScheme.onSurfaceVariant,
652 )
653 }
654 }
655 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
656 IconButton(onClick = { wrap = !wrap }) {
657 Icon(
658 Icons.AutoMirrored.Filled.WrapText,
659 contentDescription = wrapLabel,
660 modifier = Modifier.size(18.dp),
661 tint = if (wrap) MaterialTheme.colorScheme.primary
662 else MaterialTheme.colorScheme.onSurfaceVariant,
663 )
664 }
665 IconButton(
666 onClick = {
667 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
668 copied = true
669 // Haptic to match every other copy affordance in the app (the code-fence
670 // copy is the most-used copy action in a coding assistant).
671 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
672 },
673 ) {
674 Icon(
675 if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
676 contentDescription = if (copied) context.getString(R.string.copied)
677 else context.getString(R.string.copy),
678 modifier = Modifier.size(18.dp),
679 tint = if (copied) MaterialTheme.colorScheme.primary
680 else MaterialTheme.colorScheme.onSurfaceVariant,
681 )
682 }
683 }
684 // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
685 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
686 SelectionContainer {
687 Text(
688 text = highlighted,
689 style = codeStyle,
690 modifier = if (wrap) {
691 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
692 } else {
693 Modifier
694 .horizontalScroll(rememberScrollState())
695 .fillMaxWidth()
696 .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
697 },
698 )
699 }
700 if (canCollapse && !expanded) {
701 val hidden = lineCount - NetworkConfig.collapsedCodeLineThreshold
702 TextButton(
703 onClick = { expanded = true },
704 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
705 modifier = Modifier
706 .defaultMinSize(minHeight = 48.dp)
707 .padding(bottom = 4.dp),
708 ) {
709 Text(pluralStringResource(R.plurals.show_more_lines, hidden, hidden))
710 }
711 }
712 }
715/**
716 * Custom markdown image component that routes `![alt](url)` through [RemoteImage] so
717 * server-relative image URLs get the same Basic auth + same-origin handling as image
718 * attachments. The library's default image loader has no notion of the opencode server's
719 * auth, so without this a `![diagram](/media/foo.png)` in an assistant reply would fail
720 * to load. When the URL can't be extracted or doesn't resolve to a loadable model, the
721 * alt text is shown as a muted placeholder so the image isn't silently invisible.
722 */
723@Composable
724private fun MarkdownImage(model: MarkdownComponentModel, ctx: ImageLoadContext) {
725 val node = model.node
726 val content = model.content
727 // Extract the URL from the IMAGE node's LINK_DESTINATION child, and the alt text from
728 // the LINK_TEXT child. The AST structure for `![alt](url)` is:
729 // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
730 val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
731 if (url.isNullOrBlank()) {
732 // No URL — show the alt text (or a placeholder) so the image isn't invisible. The
733 // earlier `"[$alt]".ifBlank { "[image]" }` was a bug: when alt is blank, "[$alt]" is
734 // "[]" — a non-blank string — so the [image] fallback never fired and the user saw "[]".
735 val placeholder = if (alt.isNotBlank()) alt else stringResource(R.string.markdown_image_placeholder)
736 Text(
737 placeholder,
738 style = MaterialTheme.typography.bodyMedium,
739 color = MaterialTheme.colorScheme.onSurfaceVariant,
740 modifier = Modifier.padding(vertical = 4.dp),
741 )
742 return
743 }
744 // Build a FilePart so RemoteImage's existing resolution (data URI, same-origin, auth)
745 // handles the URL. The part carries the URL and alt text as the filename.
746 val part = FilePart(url = url, filename = alt.ifBlank { null })
747 RemoteImage(part = part, ctx = ctx, modifier = Modifier.fillMaxWidth())
750/** Extract the URL and alt text from a markdown IMAGE AST node. Returns (url, alt) where
751 * url may be null when no LINK_DESTINATION child is present. */
752private fun extractImageUrlAndAlt(node: ASTNode, content: String): Pair<String?, String> {
753 var url: String? = null
754 var alt = ""
755 for (child in node.children) {
756 when (child.type) {
757 MarkdownElementTypes.LINK_DESTINATION -> {
758 url = child.getTextInNode(content).toString().trim().ifBlank { null }
759 }
760 MarkdownElementTypes.LINK_TEXT -> {
761 alt = child.getTextInNode(content).toString()
762 .removePrefix("[").removeSuffix("]").trim()
763 }
764 }
765 }
766 return url to alt
769/**
770 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
771 * typewriter feel. Respects reduced motion (callers gate it). The blink is a gentle 1s alpha
772 * pulse; the caret sits inline at the leading edge so it reads as the next character arriving.
773 */
774@Composable
775private fun StreamingCaret() {
776 val transition = rememberInfiniteTransition(label = "caret")
777 val alpha by transition.animateFloat(
778 initialValue = 0.2f,
779 targetValue = 1f,
780 animationSpec = infiniteRepeatable(tween(NetworkConfig.streamingCaretPeriodMs), RepeatMode.Reverse),
781 label = "caretAlpha",
782 )
783 Text(
784 "▋",
785 style = MaterialTheme.typography.bodyLarge,
786 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
787 modifier = Modifier.semantics { invisibleToUser() },
788 )
791/**
792 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
793 * label itself. Returns null for indented blocks or fences with no info string. Extracted
794 * as a pure function for testability.
795 */
796internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
797 if (!isFenced) return null
798 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
799 // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
800 // ````kotlin), not a fixed 3 chars, before reading the info string.
801 val info = first.trimStart('`', '~').trim()
802 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
803 // first whitespace-delimited token is the language. Split on ANY whitespace (CommonMark
804 // ends the language at the first whitespace char), not just a space, so a tab-delimited
805 // info string ("```ts\ttitle=foo") still yields "ts" rather than the whole run.
806 return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
809/**
810 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
811 * only the code content is returned. Extracted as a pure function for testability.
812 *
813 * @param raw the raw text of the code node (including fence markers for fenced blocks)
814 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
815 */
816internal fun extractCodeText(raw: String, isFenced: Boolean): String {
817 if (!isFenced) return raw.trimIndent()
818 val body = raw.lines().drop(1).toMutableList()
819 // Strip the closing fence. getTextInNode can include a trailing newline, making the true last
820 // line "" — so locate the last NON-blank line and drop from there, rather than only checking
821 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
822 // displayed/copied code).
823 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
824 // CommonMark allows the closing fence to be indented up to 3 spaces, and a fence nested inside
825 // a list item keeps that indentation in the node's raw text — so trim leading whitespace before
826 // the marker check. Without it, an indented closing ```/~~~ fails startsWith and leaks into the
827 // displayed/copied code (the same leak a prior fix closed for the trailing-newline case).
828 val closer = if (lastNonBlank >= 0) body[lastNonBlank].trimStart() else ""
829 if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
830 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
831 }
832 return body.joinToString("\n").trimEnd()
835/** Copy [text] to the system clipboard and show a confirmation toast (except on Android 13+,
836 * which renders its own system copy confirmation — a toast there would be a redundant double). */
837internal fun copyToClipboard(context: Context, label: String, text: String = label) {
838 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
839 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
840 showCopyToast(context, context.getString(R.string.copied))
843/** Toast that self-suppresses on Android 13+ (Build.VERSION_CODES.TIRAMISU), where the platform
844 * already shows a copy confirmation. Use for clipboard-copy feedback so it isn't doubled. */
845internal fun showCopyToast(context: Context, message: String) {
846 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
847 showToast(context, message)
850private var lastToast: Toast? = null
852internal fun showToast(context: Context, message: String) {
853 lastToast?.cancel()
854 lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }