git.iko.soy/opencode-android
on 06fd41b help sign in
433 lines 22 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.horizontalScroll
15import androidx.compose.foundation.layout.Arrangement
16import androidx.compose.foundation.layout.Box
17import androidx.compose.foundation.layout.Column
18import androidx.compose.foundation.layout.Row
19import androidx.compose.foundation.layout.defaultMinSize
20import androidx.compose.foundation.layout.fillMaxWidth
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.text.selection.SelectionContainer
25import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.WrapText
27import androidx.compose.material.icons.filled.Check
28import androidx.compose.material.icons.filled.ContentCopy
29import androidx.compose.material.icons.filled.ExpandLess
30import androidx.compose.material.icons.filled.ExpandMore
31import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.Surface
35import androidx.compose.material3.Text
36import androidx.compose.material3.TextButton
37import androidx.compose.material3.Typography
38import androidx.compose.runtime.Composable
39import androidx.compose.runtime.LaunchedEffect
40import androidx.compose.runtime.getValue
41import androidx.compose.runtime.mutableStateOf
42import androidx.compose.runtime.remember
43import androidx.compose.runtime.rememberUpdatedState
44import androidx.compose.runtime.saveable.rememberSaveable
45import androidx.compose.runtime.setValue
46import androidx.compose.runtime.snapshotFlow
47import androidx.compose.ui.Alignment
48import androidx.compose.ui.Modifier
49import androidx.compose.ui.draw.clip
50import androidx.compose.ui.platform.LocalContext
51import androidx.compose.ui.platform.LocalHapticFeedback
52import androidx.compose.ui.res.pluralStringResource
53import androidx.compose.ui.res.stringResource
54import androidx.compose.ui.text.AnnotatedString
55import androidx.compose.ui.text.TextStyle
56import androidx.compose.ui.text.font.FontFamily
57import androidx.compose.ui.unit.dp
58import kotlinx.coroutines.flow.conflate
59import kotlinx.coroutines.delay
60import com.mikepenz.markdown.compose.components.MarkdownComponentModel
61import com.mikepenz.markdown.compose.components.markdownComponents
62import com.mikepenz.markdown.m3.Markdown
63import org.intellij.markdown.MarkdownElementTypes
64import org.intellij.markdown.ast.getTextInNode
65import soy.iko.opencode.R
66import soy.iko.opencode.data.network.NetworkConfig
68/**
69 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
70 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
71 * Code blocks/fences get an inline copy button.
72 *
73 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
74 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
75 * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
76 * copies all TextParts; this complements it with partial selection.
77 *
78 * During streaming, the full markdown is re-parsed on every token (the library re-parses
79 * whenever the content string changes). To avoid O(n²) work during long responses, the
80 * rendered content is throttled — the latest [markdown] is committed to the renderer at
81 * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
82 *
83 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
84 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
85 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
86 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
87 * starving the render and showing stale content until the stream pauses. The conflated
88 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
89 * so rendering always progresses even under continuous fast streaming.
90 *
91 * When [streaming] is false (the common case: every non-active message in the chat list),
92 * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
93 * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
94 */
95@Composable
96fun MarkdownText(
97 markdown: String,
98 modifier: Modifier = Modifier,
99 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
100 streaming: Boolean = false,
101) {
102 val scale = LocalChatTextScale.current
103 if (scale == 1f) {
104 MarkdownBody(markdown, modifier, streaming)
105 } else {
106 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
107 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
108 // the renderer derives its typography from MaterialTheme, so this reaches every
109 // element without plumbing a scale through the library. colorScheme/shapes default
110 // to the current theme's, so only sizes change. Memoized so a recomposition that
111 // doesn't change the scale doesn't rebuild the 15-role Typography.
112 val base = MaterialTheme.typography
113 val scaled = remember(scale, base) { base.scaledBy(scale) }
114 MaterialTheme(typography = scaled) {
115 MarkdownBody(markdown, modifier, streaming)
116 }
117 }
120@Composable
121private fun MarkdownBody(
122 markdown: String,
123 modifier: Modifier = Modifier,
124 streaming: Boolean = false,
125) {
126 val components = remember {
127 markdownComponents(
128 codeFence = { CodeWithCopy(it) },
129 codeBlock = { CodeWithCopy(it) },
130 )
131 }
132 if (streaming) {
133 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
134 val markdownState = rememberUpdatedState(markdown)
135 var renderedContent by remember { mutableStateOf(markdown) }
136 // A SINGLE long-lived effect (keyed on Unit), per this file's design. Keying on the
137 // content — even a prefix like markdown.take(32) — cancels and restarts the effect as the
138 // first characters stream in (the prefix changes on nearly every early token), so the
139 // in-flight delay never completes and the throttle is defeated at the start of every
140 // response. A streaming MarkdownBody instance only ever grows its content (a different
141 // message is a fresh composition with its own remember), so no reset key is needed.
142 // conflate() coalesces a burst of token updates into a single emission so the collector
143 // sees only the latest value after the previous delay completes. Plain collect (not
144 // collectLatest) is critical: collectLatest would cancel the delay on every new value,
145 // reintroducing the same starvation a keyed effect has.
146 LaunchedEffect(Unit) {
147 snapshotFlow { markdownState.value }
148 .conflate()
149 .collect { md ->
150 // Only throttle when the content is growing incrementally (streaming): a
151 // shorter switch or initial render proceeds immediately. Checking length is
152 // O(1) vs. startsWith which is O(n) in the rendered content length — during a
153 // long streaming response this runs every throttle cycle. Within one streaming
154 // composition, growing length means appending (streaming), not a rewrite.
155 if (renderedContent.isNotEmpty() &&
156 md.length > renderedContent.length
157 ) {
158 delay(NetworkConfig.streamingThrottleMs)
159 }
160 renderedContent = md
161 }
162 }
163 // SelectionContainer is intentionally omitted during streaming: the content is
164 // changing every ~50ms, and an active selection would be invalidated (and the
165 // selection handles would flicker) on each throttle commit. Partial selection
166 // becomes available once the stream finishes; the per-message Copy button in
167 // MessageBubble copies all TextParts at any time.
168 Markdown(
169 content = renderedContent,
170 modifier = modifier,
171 components = components,
172 )
173 // A blinking caret at the end of a streaming reply gives a "live" typewriter feel.
174 // Skipped under reduced motion (where blinking is discouraged). It's a tiny separate
175 // element so the markdown AST isn't re-parsed to toggle it.
176 if (!LocalReducedMotion.current) StreamingCaret()
177 } else {
178 SelectionContainer {
179 Markdown(
180 content = markdown,
181 modifier = modifier,
182 components = components,
183 )
184 }
185 }
188/**
189 * Renders a fenced or indented code block with a header toolbar: the fence's language
190 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
191 * copy button. Self-contained so it doesn't depend on the library's internal code-block
192 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
193 * be flipped per-block.
194 *
195 * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on a long
196 * response), code blocks over [COLLAPSED_CODE_LINES] lines render only the head until the
197 * user expands — mirroring the collapse already applied to tool output and diffs.
198 */
199@Composable
200private fun CodeWithCopy(model: MarkdownComponentModel) {
201 val context = LocalContext.current
202 // Key on the content string + the AST node's offset range (stable ints) instead of
203 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
204 // ASTNode), so remembering on it re-executes extract* on every recomposition.
205 val node = model.node
206 val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
207 val raw = remember(model.content, node.startOffset, node.endOffset) {
208 node.getTextInNode(model.content).toString()
209 }
210 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
211 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
212 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
213 // A long code fence can carry hundreds of lines; computing lineCount walks the string
214 // once, memoized so a recomposition that doesn't change `code` (e.g. a wrap/expand flip)
215 // doesn't re-walk it.
216 val lineCount = remember(code) { code.count { it == '\n' } + 1 }
217 val canCollapse = lineCount > COLLAPSED_CODE_LINES
218 // Key expand state on the content so a growing (streaming) block re-seeds it. Collapsing
219 // primarily benefits completed messages; a streaming block that crosses the cap may snap
220 // to collapsed on the token that crosses it, which is acceptable (the head stays visible).
221 var expanded by rememberSaveable(code) { mutableStateOf(false) }
222 val displayCode = if (canCollapse && !expanded) {
223 remember(code, expanded) {
224 code.lineSequence().take(COLLAPSED_CODE_LINES).joinToString("\n")
225 }
226 } else {
227 code
228 }
229 // Heuristic syntax highlighting, reusing the same highlighter the file viewer uses.
230 // Resolved from the fence's info string (e.g. "kotlin"), not a filename. Memoized so a
231 // scroll-induced recomposition doesn't re-tokenize; during streaming the MarkdownBody
232 // throttle coalesces re-highlights. Falls back to plain text for unknown/missing tags
233 // (NONE) so un-tagged fences render unchanged. Re-derived when displayCode changes so a
234 // collapsed head and the full expanded block are both highlighted correctly.
235 val palette = rememberHighlightPalette()
236 val syntax = remember(language) { language?.let { syntaxForLanguageTag(it) } }
237 val highlighted = remember(displayCode, syntax, palette) {
238 if (syntax != null) highlightCode(displayCode, syntax, palette) else AnnotatedString(displayCode)
239 }
240 // Default per-block wrap from the global preference; re-seed if the preference changes
241 // while this block is composed (rememberSaveable would over-persist across blocks).
242 val defaultWrap = LocalCodeWrap.current
243 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
244 // Briefly flip the copy button to a checkmark after a successful copy so the user gets
245 // visible confirmation even on Android 13+, where the copy toast is suppressed in favor of
246 // the platform's tiny (and easy to miss) confirmation chip.
247 var copied by remember { mutableStateOf(false) }
248 val haptics = LocalHapticFeedback.current
249 LaunchedEffect(copied) {
250 if (copied) {
251 delay(1200)
252 copied = false
253 }
254 }
255 Column(
256 modifier = Modifier
257 .fillMaxWidth()
258 .clip(MaterialTheme.shapes.small)
259 .background(MaterialTheme.colorScheme.surfaceVariant),
260 ) {
261 Row(
262 modifier = Modifier
263 .fillMaxWidth()
264 .padding(start = 12.dp, end = 2.dp),
265 verticalAlignment = Alignment.CenterVertically,
266 ) {
267 Text(
268 language ?: context.getString(R.string.code),
269 style = MaterialTheme.typography.labelSmall,
270 color = MaterialTheme.colorScheme.onSurfaceVariant,
271 modifier = Modifier.weight(1f),
272 )
273 if (canCollapse) {
274 val moreLines = lineCount - COLLAPSED_CODE_LINES
275 IconButton(onClick = { expanded = !expanded }) {
276 Icon(
277 if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
278 contentDescription = if (expanded) context.getString(R.string.show_less)
279 else context.resources.getQuantityString(R.plurals.show_more_lines, moreLines, moreLines),
280 modifier = Modifier.size(18.dp),
281 tint = MaterialTheme.colorScheme.onSurfaceVariant,
282 )
283 }
284 }
285 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
286 IconButton(onClick = { wrap = !wrap }) {
287 Icon(
288 Icons.AutoMirrored.Filled.WrapText,
289 contentDescription = wrapLabel,
290 modifier = Modifier.size(18.dp),
291 tint = if (wrap) MaterialTheme.colorScheme.primary
292 else MaterialTheme.colorScheme.onSurfaceVariant,
293 )
294 }
295 IconButton(
296 onClick = {
297 copyToClipboard(context, context.getString(R.string.clip_label_code), code)
298 copied = true
299 // Haptic to match every other copy affordance in the app (the code-fence
300 // copy is the most-used copy action in a coding assistant).
301 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
302 },
303 ) {
304 Icon(
305 if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
306 contentDescription = if (copied) context.getString(R.string.copied)
307 else context.getString(R.string.copy),
308 modifier = Modifier.size(18.dp),
309 tint = if (copied) MaterialTheme.colorScheme.primary
310 else MaterialTheme.colorScheme.onSurfaceVariant,
311 )
312 }
313 }
314 // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
315 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
316 SelectionContainer {
317 Text(
318 text = highlighted,
319 style = codeStyle,
320 modifier = if (wrap) {
321 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
322 } else {
323 Modifier
324 .horizontalScroll(rememberScrollState())
325 .fillMaxWidth()
326 .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
327 },
328 )
329 }
330 if (canCollapse && !expanded) {
331 val hidden = lineCount - COLLAPSED_CODE_LINES
332 TextButton(
333 onClick = { expanded = true },
334 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
335 modifier = Modifier
336 .defaultMinSize(minHeight = 48.dp)
337 .padding(bottom = 4.dp),
338 ) {
339 Text(pluralStringResource(R.plurals.show_more_lines, hidden, hidden))
340 }
341 }
342 }
345/** Max lines a code fence renders before collapsing to a head with a "show more" affordance. */
346private const val COLLAPSED_CODE_LINES = 200
348/**
349 * A small blinking caret shown at the tail of a streaming assistant reply for a "live"
350 * typewriter feel. Respects reduced motion (callers gate it). The blink is a gentle 1s alpha
351 * pulse; the caret sits inline at the leading edge so it reads as the next character arriving.
352 */
353@Composable
354private fun StreamingCaret() {
355 val transition = rememberInfiniteTransition(label = "caret")
356 val alpha by transition.animateFloat(
357 initialValue = 0.2f,
358 targetValue = 1f,
359 animationSpec = infiniteRepeatable(tween(550), RepeatMode.Reverse),
360 label = "caretAlpha",
361 )
362 Text(
363 "▋",
364 style = MaterialTheme.typography.bodyLarge,
365 color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
366 )
369/**
370 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
371 * label itself. Returns null for indented blocks or fences with no info string. Extracted
372 * as a pure function for testability.
373 */
374internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
375 if (!isFenced) return null
376 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
377 // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
378 // ````kotlin), not a fixed 3 chars, before reading the info string.
379 val info = first.trimStart('`', '~').trim()
380 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
381 // first whitespace-delimited token is the language. Split on ANY whitespace (CommonMark
382 // ends the language at the first whitespace char), not just a space, so a tab-delimited
383 // info string ("```ts\ttitle=foo") still yields "ts" rather than the whole run.
384 return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
387/**
388 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
389 * only the code content is returned. Extracted as a pure function for testability.
390 *
391 * @param raw the raw text of the code node (including fence markers for fenced blocks)
392 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
393 */
394internal fun extractCodeText(raw: String, isFenced: Boolean): String {
395 if (!isFenced) return raw.trimIndent()
396 val body = raw.lines().drop(1).toMutableList()
397 // Strip the closing fence. getTextInNode can include a trailing newline, making the true last
398 // line "" — so locate the last NON-blank line and drop from there, rather than only checking
399 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
400 // displayed/copied code).
401 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
402 // CommonMark allows the closing fence to be indented up to 3 spaces, and a fence nested inside
403 // a list item keeps that indentation in the node's raw text — so trim leading whitespace before
404 // the marker check. Without it, an indented closing ```/~~~ fails startsWith and leaks into the
405 // displayed/copied code (the same leak a prior fix closed for the trailing-newline case).
406 val closer = if (lastNonBlank >= 0) body[lastNonBlank].trimStart() else ""
407 if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
408 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
409 }
410 return body.joinToString("\n").trimEnd()
413/** Copy [text] to the system clipboard and show a confirmation toast (except on Android 13+,
414 * which renders its own system copy confirmation — a toast there would be a redundant double). */
415internal fun copyToClipboard(context: Context, label: String, text: String = label) {
416 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
417 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
418 showCopyToast(context, context.getString(R.string.copied))
421/** Toast that self-suppresses on Android 13+ (Build.VERSION_CODES.TIRAMISU), where the platform
422 * already shows a copy confirmation. Use for clipboard-copy feedback so it isn't doubled. */
423internal fun showCopyToast(context: Context, message: String) {
424 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
425 showToast(context, message)
428private var lastToast: Toast? = null
430internal fun showToast(context: Context, message: String) {
431 lastToast?.cancel()
432 lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }