1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.widget.Toast
7import androidx.compose.foundation.background
8import androidx.compose.foundation.horizontalScroll
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.rememberScrollState
14import androidx.compose.foundation.text.selection.SelectionContainer
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.ContentCopy
17import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Surface
21import androidx.compose.material3.Text
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.LaunchedEffect
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.remember
27import androidx.compose.runtime.rememberUpdatedState
28import androidx.compose.runtime.setValue
29import androidx.compose.runtime.snapshotFlow
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.draw.clip
33import androidx.compose.ui.platform.LocalContext
34import androidx.compose.ui.text.TextStyle
35import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.unit.dp
37import kotlinx.coroutines.flow.conflate
38import kotlinx.coroutines.delay
39import com.mikepenz.markdown.compose.components.MarkdownComponentModel
40import com.mikepenz.markdown.compose.components.markdownComponents
41import com.mikepenz.markdown.m3.Markdown
42import org.intellij.markdown.MarkdownElementTypes
43import org.intellij.markdown.ast.ASTNode
44import org.intellij.markdown.ast.getTextInNode
45import soy.iko.opencode.R
46import soy.iko.opencode.data.network.NetworkConfig
49 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
50 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
51 * Code blocks/fences get an inline copy button.
53 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
54 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
55 * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
56 * copies all TextParts; this complements it with partial selection.
58 * During streaming, the full markdown is re-parsed on every token (the library re-parses
59 * whenever the content string changes). To avoid O(n²) work during long responses, the
60 * rendered content is throttled — the latest [markdown] is committed to the renderer at
61 * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
63 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
64 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
65 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
66 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
67 * starving the render and showing stale content until the stream pauses. The conflated
68 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
69 * so rendering always progresses even under continuous fast streaming.
71 * When [streaming] is false (the common case: every non-active message in the chat list),
72 * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
73 * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
78 modifier: Modifier = Modifier,
79 style: TextStyle = MaterialTheme.typography.bodyLarge,
80 streaming: Boolean = false,
82 val context = LocalContext.current
83 val components = remember {
85 codeFence = { CodeWithCopy(it) },
86 codeBlock = { CodeWithCopy(it) },
90 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
91 val markdownState = rememberUpdatedState(markdown)
92 // Keyed to a content-prefix so switching to a different message resets
93 // immediately instead of showing the old message for one frame.
94 val prefix = markdown.take(32)
95 var renderedContent by remember(prefix) { mutableStateOf(markdown) }
96 // conflate() coalesces a burst of token updates into a single emission so the
97 // collector sees only the latest value after the previous delay completes. Plain
98 // collect (not collectLatest) is critical: collectLatest would cancel the delay
99 // on every new value, reintroducing the same starvation the keyed effect had.
100 LaunchedEffect(prefix) {
101 snapshotFlow { markdownState.value }
104 // Only throttle when the content is growing incrementally (streaming):
105 // a content switch or initial render proceeds immediately. Checking
106 // length is O(1) vs. startsWith which is O(n) in the rendered content
107 // length — during a long streaming response this runs every throttle
108 // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
109 // content switches; within one keyed cycle, growing length means
110 // appending (streaming), not a rewrite.
111 if (renderedContent.isNotEmpty() &&
112 md.length > renderedContent.length
114 delay(NetworkConfig.streamingThrottleMs)
119 // SelectionContainer is intentionally omitted during streaming: the content is
120 // changing every ~50ms, and an active selection would be invalidated (and the
121 // selection handles would flicker) on each throttle commit. Partial selection
122 // becomes available once the stream finishes; the per-message Copy button in
123 // MessageBubble copies all TextParts at any time.
125 content = renderedContent,
127 components = components,
134 components = components,
141 * Renders a fenced or indented code block with a small copy button in the top-right corner.
142 * Self-contained so it doesn't depend on the library's internal code-block composables.
145private fun CodeWithCopy(model: MarkdownComponentModel) {
146 val context = LocalContext.current
147 // Key on the content string + the AST node's offset range (stable ints) instead of
148 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
149 // ASTNode), so remembering on it re-executes extractCode on every recomposition.
150 val node = model.node
151 val code = remember(model.content, node.startOffset, node.endOffset) {
152 extractCode(model.content, node)
154 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
158 .clip(MaterialTheme.shapes.small)
159 .background(MaterialTheme.colorScheme.surfaceVariant),
165 .horizontalScroll(rememberScrollState())
167 .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
170 color = MaterialTheme.colorScheme.surface,
171 shape = MaterialTheme.shapes.extraSmall,
173 .align(Alignment.TopEnd)
177 onClick = { copyToClipboard(context, "code", code) },
178 modifier = Modifier.size(48.dp),
181 Icons.Filled.ContentCopy,
182 contentDescription = context.getString(R.string.copy),
183 modifier = Modifier.size(14.dp),
184 tint = MaterialTheme.colorScheme.onSurfaceVariant,
192 * Extract the raw code text from a markdown code node, stripping fence markers
193 * (```/~~~ and the language tag) so only the code content is copied.
195private fun extractCode(content: String, node: ASTNode): String =
196 extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
199 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
200 * only the code content is returned. Extracted as a pure function for testability.
202 * @param raw the raw text of the code node (including fence markers for fenced blocks)
203 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
205internal fun extractCodeText(raw: String, isFenced: Boolean): String {
206 if (!isFenced) return raw.trimIndent()
207 val lines = raw.lines()
208 val body = lines.drop(1).toMutableList()
209 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
210 body.removeAt(body.lastIndex)
212 return body.joinToString("\n").trimEnd()
215/** Copy [text] to the system clipboard and show a confirmation toast. */
216internal fun copyToClipboard(context: Context, label: String, text: String = label) {
217 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
218 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
219 showToast(context, context.getString(R.string.copied))
222private var lastToast: Toast? = null
224internal fun showToast(context: Context, message: String) {
226 lastToast = Toast.makeText(context, 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