1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
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 org.intellij.markdown.MarkdownElementTypes
88import org.intellij.markdown.ast.ASTNode
89import org.intellij.markdown.ast.getTextInNode
90import soy.iko.opencode.R
91import soy.iko.opencode.data.model.FilePart
92import soy.iko.opencode.data.network.NetworkConfig
94/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
95 * annotator wrapper, so [ParagraphWithInlineCodeCopy]'s `pointerInput` can resolve a long-press
96 * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
97 * for link-tap hit-testing. */
98private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
101 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
102 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
103 * Code blocks/fences get an inline copy button.
105 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
106 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
107 * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
108 * copies all TextParts; this complements it with partial selection.
110 * During streaming, the full markdown is re-parsed on every token (the library re-parses
111 * whenever the content string changes). To avoid O(n²) work during long responses, the
112 * rendered content is throttled — the latest [markdown] is committed to the renderer at
113 * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
115 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
116 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
117 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
118 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
119 * starving the render and showing stale content until the stream pauses. The conflated
120 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
121 * so rendering always progresses even under continuous fast streaming.
123 * When [streaming] is false (the common case: every non-active message in the chat list),
124 * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
125 * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
130 modifier: Modifier = Modifier,
131 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
132 streaming: Boolean = false,
133 imageContext: ImageLoadContext? = null,
135 val scale = LocalChatTextScale.current
137 MarkdownBody(markdown, modifier, streaming, imageContext)
139 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
140 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
141 // the renderer derives its typography from MaterialTheme, so this reaches every
142 // element without plumbing a scale through the library. colorScheme/shapes default
143 // to the current theme's, so only sizes change. Memoized so a recomposition that
144 // doesn't change the scale doesn't rebuild the 15-role Typography.
145 val base = MaterialTheme.typography
146 val scaled = remember(scale, base) { base.scaledBy(scale) }
147 MaterialTheme(typography = scaled) {
148 MarkdownBody(markdown, modifier, streaming, imageContext)
154private fun MarkdownBody(
156 modifier: Modifier = Modifier,
157 streaming: Boolean = false,
158 imageContext: ImageLoadContext? = null,
160 // Provide a custom image component when an ImageLoadContext is available so markdown
161 // image tags () route through RemoteImage (Basic auth + same-origin guard).
162 // Without this the library's default image loader fetches with no auth and fails on
163 // server-relative URLs. When no context is available, fall back to the library default
164 // by not overriding the image component.
165 // The `paragraph` component is overridden with [ParagraphWithInlineCodeCopy] so a long-press
166 // on a single-backtick inline code span copies its text — matching the copy affordance the
167 // fenced/indented code blocks already have via [CodeWithCopy].
168 val components = if (imageContext != null) {
170 codeFence = { CodeWithCopy(it) },
171 codeBlock = { CodeWithCopy(it) },
172 paragraph = { ParagraphWithInlineCodeCopy(it) },
173 image = { MarkdownImage(it, imageContext) },
178 codeFence = { CodeWithCopy(it) },
179 codeBlock = { CodeWithCopy(it) },
180 paragraph = { ParagraphWithInlineCodeCopy(it) },
184 val context = LocalContext.current
185 // Intercept link taps so the scheme can be validated before the library fires an
186 // implicit ACTION_VIEW. A hallucinated javascript:/file:/intent: URL must not launch
187 // an unexpected intent chooser. Taps are routed to a dialog offering Open (only for
188 // http/https/mailto) and Copy link. The default handler is captured here and invoked
189 // from the dialog's Open action.
190 val defaultUriHandler = LocalUriHandler.current
191 var pendingLink by remember { mutableStateOf<String?>(null) }
192 val safeUriHandler = remember {
193 object : UriHandler {
194 override fun openUri(uri: String) {
199 // In-conversation search highlighting: when LocalSearchHighlight carries a query,
200 // wrap the default MarkdownAnnotator to add a translucent background span over each
201 // case-insensitive match in the rendered text. Skipped while streaming so the
202 // throttled re-render isn't invalidated on every token. Offsets are derived from the
203 // builder's actual rendered output (not the raw markdown), so highlights land on the
204 // right characters even inside formatted spans.
205 val searchQuery = LocalSearchHighlight.current?.takeIf { it.isNotBlank() }
206 val highlightColor = MaterialTheme.colorScheme.secondaryContainer
207 val baseAnnotator = LocalMarkdownAnnotator.current
208 // Capture the inline-code typography/colors in the composable scope (the annotator lambda is
209 // NOT a @Composable scope, so it can't read LocalMarkdownTypography.current itself). The
210 // library's buildMarkdownAnnotatedString reads these in its own @Composable body for the
211 // default CODE_SPAN branch; since we intercept CODE_SPAN in the annotator, we capture them
212 // here and close over them in appendInlineCodeSpan.
213 val inlineCodeTypography = LocalMarkdownTypography.current
214 val inlineCodeColors = LocalMarkdownColors.current
215 // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
216 // CODE_SPAN range so [ParagraphWithInlineCodeCopy] can resolve a long-press offset to the
217 // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
218 // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
219 // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
220 // rather than extracted to a helper to keep this file under detekt's TooManyFunctions threshold.
221 val inlineCodeAnnotator = remember(baseAnnotator, inlineCodeTypography, inlineCodeColors) {
222 val codeStyle = inlineCodeTypography.inlineCode.copy(
223 color = inlineCodeTypography.inlineCode.color,
224 background = inlineCodeColors.inlineCodeBackground,
226 DefaultMarkdownAnnotator(
227 annotate = { content, node ->
228 if (node.type == MarkdownElementTypes.CODE_SPAN) {
231 val codeStart = this.length
232 // Drop the opening/closing backtick children (matching the library's internal
233 // innerList(), which is inaccessible from here).
234 val codeText = StringBuilder()
235 for (child in node.children) {
236 if (child.type == org.intellij.markdown.MarkdownTokenTypes.BACKTICK) continue
237 val text = child.getTextInNode(content).toString()
238 codeText.append(text)
241 val codeEnd = this.length
242 if (codeEnd > codeStart) {
243 pushStringAnnotation(MARKDOWN_TAG_INLINE_CODE, codeText.toString())
250 baseAnnotator.annotate?.invoke(this, content, node) ?: false
253 config = DefaultMarkdownAnnotatorConfig(),
256 // Compose the inline-code tagger with the search-highlight wrapper when a query is active.
257 // The highlight wrapper runs on top, so it sees the final (tagged) builder output and can
258 // highlight matches inside inline code too.
259 val effectiveAnnotator = if (searchQuery != null && !streaming) {
260 remember(inlineCodeAnnotator, searchQuery, highlightColor) {
261 val inner = inlineCodeAnnotator.annotate
262 DefaultMarkdownAnnotator(
263 annotate = { text, node ->
264 val startLen = this.length
265 val handled = inner?.invoke(this, text, node) ?: false
266 val endLen = this.length
267 if (endLen > startLen) {
270 this.toString().substring(startLen, endLen),
278 config = DefaultMarkdownAnnotatorConfig(),
285 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
286 val markdownState = rememberUpdatedState(markdown)
287 var renderedContent by remember { mutableStateOf(markdown) }
288 // A SINGLE long-lived effect (keyed on Unit), per this file's design. Keying on the
289 // content — even a prefix like markdown.take(32) — cancels and restarts the effect as the
290 // first characters stream in (the prefix changes on nearly every early token), so the
291 // in-flight delay never completes and the throttle is defeated at the start of every
292 // response. A streaming MarkdownBody instance only ever grows its content (a different
293 // message is a fresh composition with its own remember), so no reset key is needed.
294 // conflate() coalesces a burst of token updates into a single emission so the collector
295 // sees only the latest value after the previous delay completes. Plain collect (not
296 // collectLatest) is critical: collectLatest would cancel the delay on every new value,
297 // reintroducing the same starvation a keyed effect has.
298 LaunchedEffect(Unit) {
299 snapshotFlow { markdownState.value }
302 // Only throttle when the content is growing incrementally (streaming): a
303 // shorter switch or initial render proceeds immediately. Checking length is
304 // O(1) vs. startsWith which is O(n) in the rendered content length — during a
305 // long streaming response this runs every throttle cycle. Within one streaming
306 // composition, growing length means appending (streaming), not a rewrite.
307 if (renderedContent.isNotEmpty() &&
308 md.length > renderedContent.length
310 delay(NetworkConfig.streamingThrottleMs)
315 // SelectionContainer is intentionally omitted during streaming: the content is
316 // changing every ~50ms, and an active selection would be invalidated (and the
317 // selection handles would flicker) on each throttle commit. Partial selection
318 // becomes available once the stream finishes; the per-message Copy button in
319 // MessageBubble copies all TextParts at any time.
320 // A polite live region announces streaming content to TalkBack users. The throttle
321 // (50ms) plus TalkBack's own polite-queue coalescing keeps announcements from
322 // interrupting on every token; the reader hears the reply grow in measured chunks.
323 CompositionLocalProvider(
324 LocalUriHandler provides safeUriHandler,
327 content = renderedContent,
328 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
329 components = components,
330 annotator = effectiveAnnotator,
333 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
334 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
335 // element so the markdown AST isn't re-parsed to toggle it.
336 if (!LocalReducedMotion.current) StreamingCaret()
338 val content: @Composable () -> Unit = {
342 modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
343 components = components,
344 annotator = effectiveAnnotator,
348 CompositionLocalProvider(
349 LocalUriHandler provides safeUriHandler,
354 // Open/copy dialog for the most recently tapped markdown link. Offer Open only for
355 // schemes the app is willing to hand to the system (http/https/mailto); for anything
356 // else, copy is the only escape so a model hallucination can't fire an unexpected intent.
357 pendingLink?.let { url ->
360 canOpen = isSafeLinkScheme(url),
361 onDismiss = { pendingLink = null },
364 runCatching { defaultUriHandler.openUri(url) }
368 copyToClipboard(context, context.getString(R.string.copy_link), url)
375 * Adds a translucent background span over each case-insensitive occurrence of [query] found
376 * in [chunk], mapping the chunk-relative offsets into the builder via [baseOffset]. Called
377 * from the search-highlight annotator wrapper; [chunk] is the text the default annotator
378 * appended to the builder for a single markdown node, so the offsets line up with what the
379 * user actually sees (markdown syntax already stripped).
381private fun addSearchHighlights(
382 builder: AnnotatedString.Builder,
389 if (q.isEmpty()) return
390 var idx = chunk.indexOf(q, ignoreCase = true)
393 SpanStyle(background = color),
395 baseOffset + idx + q.length,
397 idx = chunk.indexOf(q, idx + q.length, ignoreCase = true)
401/** Schemes the app is willing to forward to the system's URI handler from a markdown link. */
402private fun isSafeLinkScheme(uri: String): Boolean {
403 val scheme = uri.substringBefore(':', "").lowercase()
404 return scheme == "http" || scheme == "https" || scheme == "mailto"
408 * Dialog shown when the user taps a markdown link. For http/https/mailto URIs, offers Open
409 * (via the platform default handler) and Copy link. For any other scheme, only Copy is
410 * offered (with an "unsupported link type" note) so a hallucinated javascript:/file: URL
411 * can't launch an unexpected intent.
414private fun LinkActionDialog(
417 onDismiss: () -> Unit,
422 onDismissRequest = onDismiss,
426 style = MaterialTheme.typography.bodySmall,
427 color = MaterialTheme.colorScheme.onSurfaceVariant,
433 stringResource(R.string.link_unsupported),
434 style = MaterialTheme.typography.bodyMedium,
435 color = MaterialTheme.colorScheme.error,
440 androidx.compose.foundation.layout.Row {
441 TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_link)) }
443 TextButton(onClick = onOpen) { Text(stringResource(R.string.open_link)) }
448 TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
454 * Custom markdown `paragraph` component that adds a long-press-to-copy affordance for inline
455 * code spans (single-backtick `` `code` ``). The library renders CODE_SPAN inline as a styled
456 * run within the paragraph's [AnnotatedString]; there's no discrete `inlineCode` component to
457 * override. Instead, [MarkdownBody] installs an annotator that stamps each CODE_SPAN range with
458 * a [MARKDOWN_TAG_INLINE_CODE] string annotation; this component builds the paragraph's
459 * [AnnotatedString] via the library's [buildMarkdownAnnotatedString] (so the annotator runs and
460 * the tags land), then renders it with a `pointerInput` that hit-tests a long-press against the
461 * tagged ranges and copies the span's text on hit — mirroring the copy affordance fenced/indented
462 * code blocks get via [CodeWithCopy], for the common case of short inline commands/identifiers.
463 * A long-press inside a [SelectionContainer] is consumed by the selection machinery first, so
464 * the copy fires from non-selectable contexts (e.g. while streaming, where no SelectionContainer
465 * wraps the text); when selection is active the user can still copy via the selection handles.
466 * Haptic feedback matches every other copy affordance.
469private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
470 val context = LocalContext.current
471 val haptics = LocalHapticFeedback.current
472 val style = model.typography.paragraph
473 val typography = LocalMarkdownTypography.current
474 val annotator = LocalMarkdownAnnotator.current
475 // buildMarkdownAnnotatedString needs an explicit AnnotatorSettings in 0.43+ (the old
476 // overload that read LocalMarkdownAnnotator internally was removed). Build one from the
477 // current composition locals so the inline-code tagger installed in MarkdownBody still
478 // runs. The library's own MarkdownParagraph does the same.
479 val settings = DefaultAnnotatorSettings(
480 linkTextSpanStyle = typography.textLink,
481 codeSpanStyle = typography.inlineCode.toSpanStyle(),
482 annotator = annotator,
484 val styledText = buildAnnotatedString {
485 pushStyle(style.toSpanStyle())
486 buildMarkdownAnnotatedString(model.content, model.node, settings)
489 // Capture the layout result so a long-press position can be mapped to a character offset,
490 // then to a tagged range — the same hit-testing pattern the library uses for link taps.
491 var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
493 content = styledText,
495 modifier = Modifier.pointerInput(styledText) {
497 onLongPress = { pos ->
498 val result = layoutResult ?: return@detectTapGestures
499 val offset = result.getOffsetForPosition(pos)
500 val span = styledText.getStringAnnotations(MARKDOWN_TAG_INLINE_CODE, offset, offset)
502 ?: return@detectTapGestures
504 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
505 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
510 onTextLayout = { result, _ -> layoutResult = result },
515 * Renders a fenced or indented code block with a header toolbar: the fence's language
516 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
517 * copy button. Self-contained so it doesn't depend on the library's internal code-block
518 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
519 * be flipped per-block.
521 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on a long
522 * response), code blocks over [COLLAPSED_CODE_LINES] lines render only the head until the
523 * user expands — mirroring the collapse already applied to tool output and diffs.
526private fun CodeWithCopy(model: MarkdownComponentModel) {
527 val context = LocalContext.current
528 // Key on the content string + the AST node's offset range (stable ints) instead of
529 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
530 // ASTNode), so remembering on it re-executes extract* on every recomposition.
531 val node = model.node
532 val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
533 val raw = remember(model.content, node.startOffset, node.endOffset) {
534 node.getTextInNode(model.content).toString()
536 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
537 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
538 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
539 // A long code fence can carry hundreds of lines; computing lineCount walks the string
540 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
541 // doesn't re-walk it.
542 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
543 val canCollapse = lineCount > NetworkConfig.collapsedCodeLineThreshold
544 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
545 // primarily benefits completed messages; a streaming block that crosses the cap may snap
546 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
547 var expanded by rememberSaveable(code) { mutableStateOf(false) }
548 val displayCode = if (canCollapse && !expanded) {
549 remember(code, expanded) {
550 code.lineSequence().take(NetworkConfig.collapsedCodeLineThreshold).joinToString("\n")
555 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
556 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
557 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
558 // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
559 // (NONE) so un-tagged fences render unchanged. Re-derived when displayCode changes so a
560 // collapsed head and the full expanded block are both highlighted correctly.
561 val palette = rememberHighlightPalette()
562 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
563 val highlighted = remember(displayCode, syntax, palette) {
564 if (syntax != null) highlightCode(displayCode, syntax, palette) else AnnotatedString(displayCode)
566 // Default per-block wrap from the global preference; re-seed if the preference changes
567 // while this block is composed (rememberSaveable would over-persist across blocks).
568 val defaultWrap = LocalCodeWrap.current
569 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
570 // Briefly flip the copy button to a checkmark after a successful copy so the user gets
571 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
572 // the platform's tiny (and easy to miss) confirmation chip.
573 var copied by remember { mutableStateOf(false) }
574 val haptics = LocalHapticFeedback.current
575 LaunchedEffect(copied) {
577 delay(NetworkConfig.copyFeedbackMs.toLong())
584 .clip(MaterialTheme.shapes.small)
585 .background(MaterialTheme.colorScheme.surfaceVariant),
590 .padding(start = 12.dp, end = 2.dp),
591 verticalAlignment = Alignment.CenterVertically,
594 language ?: context.getString(R.string.code),
595 style = MaterialTheme.typography.labelSmall,
596 color = MaterialTheme.colorScheme.onSurfaceVariant,
597 modifier = Modifier.weight(1f),
600 val moreLines = lineCount - NetworkConfig.collapsedCodeLineThreshold
601 IconButton(onClick = { expanded = !expanded }) {
603 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
604 contentDescription = if (expanded) context.getString(R.string.show_less)
605 else context.resources.getQuantityString(R.plurals.show_more_lines, moreLines, moreLines),
606 modifier = Modifier.size(18.dp),
607 tint = MaterialTheme.colorScheme.onSurfaceVariant,
611 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
612 IconButton(onClick = { wrap = !wrap }) {
614 Icons.AutoMirrored.Filled.WrapText,
615 contentDescription = wrapLabel,
616 modifier = Modifier.size(18.dp),
617 tint = if (wrap) MaterialTheme.colorScheme.primary
618 else MaterialTheme.colorScheme.onSurfaceVariant,
623 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
625 // Haptic to match every other copy affordance in the app (the code-fence
626 // copy is the most-used copy action in a coding assistant).
627 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
631 if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
632 contentDescription = if (copied) context.getString(R.string.copied)
633 else context.getString(R.string.copy),
634 modifier = Modifier.size(18.dp),
635 tint = if (copied) MaterialTheme.colorScheme.primary
636 else MaterialTheme.colorScheme.onSurfaceVariant,
640 // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
641 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
646 modifier = if (wrap) {
647 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
650 .horizontalScroll(rememberScrollState())
652 .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
656 if (canCollapse && !expanded) {
657 val hidden = lineCount - NetworkConfig.collapsedCodeLineThreshold
659 onClick = { expanded = true },
660 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
662 .defaultMinSize(minHeight = 48.dp)
663 .padding(bottom = 4.dp),
665 Text(pluralStringResource(R.plurals.show_more_lines, hidden, hidden))
672 * Custom markdown image component that routes `` through [RemoteImage] so
673 * server-relative image URLs get the same Basic auth + same-origin handling as image
674 * attachments. The library's default image loader has no notion of the opencode server's
675 * auth, so without this a `` in an assistant reply would fail
676 * to load. When the URL can't be extracted or doesn't resolve to a loadable model, the
677 * alt text is shown as a muted placeholder so the image isn't silently invisible.
680private fun MarkdownImage(model: MarkdownComponentModel, ctx: ImageLoadContext) {
681 val node = model.node
682 val content = model.content
683 // Extract the URL from the IMAGE node's LINK_DESTINATION child, and the alt text from
684 // the LINK_TEXT child. The AST structure for `` is:
685 // IMAGE -> [LINK_LABEL, LPAREN, LINK_DESTINATION, RPAREN] (and LINK_TEXT nested)
686 val (url, alt) = remember(node, content) { extractImageUrlAndAlt(node, content) }
687 if (url.isNullOrBlank()) {
688 // No URL — show the alt text (or a placeholder) so the image isn't invisible. The
689 // earlier `"[$alt]".ifBlank { "[image]" }` was a bug: when alt is blank, "[$alt]" is
690 // "[]" — a non-blank string — so the [image] fallback never fired and the user saw "[]".
691 val placeholder = if (alt.isNotBlank()) alt else stringResource(R.string.markdown_image_placeholder)
694 style = MaterialTheme.typography.bodyMedium,
695 color = MaterialTheme.colorScheme.onSurfaceVariant,
696 modifier = Modifier.padding(vertical = 4.dp),
700 // Build a FilePart so RemoteImage's existing resolution (data URI, same-origin, auth)
701 // handles the URL. The part carries the URL and alt text as the filename.
702 val part = FilePart(url = url, filename = alt.ifBlank { null })
703 RemoteImage(part = part, ctx = ctx, modifier = Modifier.fillMaxWidth())
706/** Extract the URL and alt text from a markdown IMAGE AST node. Returns (url, alt) where
707 * url may be null when no LINK_DESTINATION child is present. */
708private fun extractImageUrlAndAlt(node: ASTNode, content: String): Pair<String?, String> {
709 var url: String? = null
711 for (child in node.children) {
713 MarkdownElementTypes.LINK_DESTINATION -> {
714 url = child.getTextInNode(content).toString().trim().ifBlank { null }
716 MarkdownElementTypes.LINK_TEXT -> {
717 alt = child.getTextInNode(content).toString()
718 .removePrefix("[").removeSuffix("]").trim()
726 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
727 * typewriter feel. Respects reduced motion (callers gate it). The blink is a gentle 1s alpha
728 * pulse; the caret sits inline at the leading edge so it reads as the next character arriving.
731private fun StreamingCaret() {
732 val transition = rememberInfiniteTransition(label = "caret")
733 val alpha by transition.animateFloat(
736 animationSpec = infiniteRepeatable(tween(NetworkConfig.streamingCaretPeriodMs), RepeatMode.Reverse),
737 label = "caretAlpha",
741 style = MaterialTheme.typography.bodyLarge,
742 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
743 modifier = Modifier.semantics { invisibleToUser() },
748 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
749 * label itself. Returns null for indented blocks or fences with no info string. Extracted
750 * as a pure function for testability.
752internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
753 if (!isFenced) return null
754 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
755 // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
756 // ````kotlin), not a fixed 3 chars, before reading the info string.
757 val info = first.trimStart('`', '~').trim()
758 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
759 // first whitespace-delimited token is the language. Split on ANY whitespace (CommonMark
760 // ends the language at the first whitespace char), not just a space, so a tab-delimited
761 // info string ("```ts\ttitle=foo") still yields "ts" rather than the whole run.
762 return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
766 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
767 * only the code content is returned. Extracted as a pure function for testability.
769 * @param raw the raw text of the code node (including fence markers for fenced blocks)
770 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
772internal fun extractCodeText(raw: String, isFenced: Boolean): String {
773 if (!isFenced) return raw.trimIndent()
774 val body = raw.lines().drop(1).toMutableList()
775 // Strip the closing fence. getTextInNode can include a trailing newline, making the true last
776 // line "" — so locate the last NON-blank line and drop from there, rather than only checking
777 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
778 // displayed/copied code).
779 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
780 // CommonMark allows the closing fence to be indented up to 3 spaces, and a fence nested inside
781 // a list item keeps that indentation in the node's raw text — so trim leading whitespace before
782 // the marker check. Without it, an indented closing ```/~~~ fails startsWith and leaks into the
783 // displayed/copied code (the same leak a prior fix closed for the trailing-newline case).
784 val closer = if (lastNonBlank >= 0) body[lastNonBlank].trimStart() else ""
785 if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
786 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
788 return body.joinToString("\n").trimEnd()
791/** Copy [text] to the system clipboard and show a confirmation toast (except on Android 13+,
792 * which renders its own system copy confirmation — a toast there would be a redundant double). */
793internal fun copyToClipboard(context: Context, label: String, text: String = label) {
794 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
795 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
796 showCopyToast(context, context.getString(R.string.copied))
799/** Toast that self-suppresses on Android 13+ (Build.VERSION_CODES.TIRAMISU), where the platform
800 * already shows a copy confirmation. Use for clipboard-copy feedback so it isn't doubled. */
801internal fun showCopyToast(context: Context, message: String) {
802 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
803 showToast(context, message)
806private var lastToast: Toast? = null
808internal fun showToast(context: Context, message: String) {
810 lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list