Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("io.noties.markwon:core:4.6.2")
implementation("io.noties.markwon:ext-latex:4.6.2")
implementation("io.noties.markwon:ext-tables:4.6.2")
implementation("io.noties.markwon:inline-parser:4.6.2")
implementation("io.noties.markwon:syntax-highlight:4.6.2") {
exclude(group = "org.jetbrains", module = "annotations-java5")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ fun ConversationScreen(
val prism4j = io.noties.prism4j.Prism4j(com.litter.android.ui.Prism4jGrammarLocator())
io.noties.markwon.Markwon.builder(context)
.usePlugin(io.noties.markwon.syntax.SyntaxHighlightPlugin.create(prism4j, io.noties.markwon.syntax.Prism4jThemeDarkula.create()))
.usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context))
.build()
} catch (_: Exception) {
io.noties.markwon.Markwon.create(context)
Expand Down Expand Up @@ -383,11 +384,11 @@ fun ConversationScreen(
LaunchedEffect(threadKey, displayedTurnCount, transcriptTailSignature, followScrollToken, streamingRenderTick) {
if (shouldFollowTail && displayedTurns.isNotEmpty()) {
val bottomAnchorIndex = conversationBottomAnchorIndex(displayedTurnCount)
if (hasPositionedInitialTail) {
listState.animateScrollToItem(bottomAnchorIndex)
} else {
if (!hasPositionedInitialTail || isThinking) {
listState.scrollToItem(bottomAnchorIndex)
hasPositionedInitialTail = true
} else {
listState.animateScrollToItem(bottomAnchorIndex)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,6 @@ private fun AssistantMessageRow(
if (data.text == renderedText) return@LaunchedEffect
if (renderedText.isEmpty()) {
renderedText = data.text
onStreamingSnapshotRendered?.invoke()
} else {
pendingText = data.text
}
Expand All @@ -442,7 +441,6 @@ private fun AssistantMessageRow(
delay(60)
renderedText = nextText
pendingText = null
onStreamingSnapshotRendered?.invoke()
}

Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.core.MarkwonTheme
import io.noties.markwon.ext.latex.JLatexMathPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin
import io.noties.markwon.syntax.SyntaxHighlightPlugin
import io.noties.prism4j.Prism4j
Expand All @@ -45,6 +46,7 @@ internal fun SelectableMarkdownText(
modifier: Modifier = Modifier,
bodySize: Float = LitterTextStyle.body,
usePhysicalDpTextSize: Boolean = false,
selectable: Boolean = true,
onTextViewReady: ((TextView) -> Unit)? = null,
) {
val context = LocalContext.current
Expand Down Expand Up @@ -85,6 +87,7 @@ internal fun SelectableMarkdownText(
textSize = resolvedTextSize,
typeface = typeface,
usePhysicalDpTextSize = usePhysicalDpTextSize,
selectable = selectable,
)
onTextViewReady?.invoke(this)
}
Expand All @@ -97,20 +100,38 @@ internal fun SelectableMarkdownText(
textSize = resolvedTextSize,
typeface = typeface,
usePhysicalDpTextSize = usePhysicalDpTextSize,
selectable = selectable,
)
markwon.setMarkdown(tv, markdown)
val renderTag = MarkdownRenderTag(
markdown = markdown,
textColor = textColor,
textSizePx = markdownTextSizePx,
typeface = typeface,
)
if (tv.tag != renderTag) {
tv.tag = renderTag
markwon.setMarkdown(tv, markdown)
}
},
modifier = modifier,
)
}

private data class MarkdownRenderTag(
val markdown: String,
val textColor: Int,
val textSizePx: Float,
val typeface: android.graphics.Typeface?,
)

internal fun configureSelectableMarkdownTextView(
textView: TextView,
textColor: Int,
linkColor: Int,
textSize: Float,
typeface: android.graphics.Typeface? = null,
usePhysicalDpTextSize: Boolean = false,
selectable: Boolean = true,
) {
textView.setTextColor(textColor)
textView.typeface = typeface
Expand All @@ -123,8 +144,12 @@ internal fun configureSelectableMarkdownTextView(
textView.linksClickable = true
textView.movementMethod = LinkMovementMethod.getInstance()
textView.setLinkTextColor(linkColor)
textView.setTextIsSelectable(true)
textView.customSelectionActionModeCallback = RunInTerminalSelectionMenu(textView)
textView.setTextIsSelectable(selectable)
textView.customSelectionActionModeCallback = if (selectable) {
RunInTerminalSelectionMenu(textView)
} else {
null
}
}

/**
Expand Down Expand Up @@ -206,6 +231,7 @@ private fun rememberConversationMarkwon(
io.noties.markwon.syntax.Prism4jThemeDarkula.create(),
),
)
.usePlugin(TablePlugin.create(context))
.usePlugin(MarkwonInlineParserPlugin.create())
.usePlugin(
JLatexMathPlugin.create(markdownTextSizePx, markdownTextSizePx * 1.12f) { builder ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.litter.android.ui.conversation

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
Expand All @@ -17,7 +15,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
Expand All @@ -33,9 +30,10 @@ import com.litter.android.ui.scaled
import uniffi.codex_mobile_client.AppMessageRenderBlock

/**
* Composable that renders streaming assistant messages with a fade-in reveal
* effect on newly appended tokens. Uses [StreamingTextCoordinator] to split
* text into a stable cached prefix and an animated frontier.
* Composable that renders streaming assistant messages. Uses
* [StreamingTextCoordinator] to split text into a stable cached prefix and a
* small frontier without repeatedly fading the active markdown block; token
* streams can update faster than a fade can complete, which reads as flicker.
*/
@Composable
fun StreamingMarkdownView(
Expand All @@ -55,12 +53,7 @@ fun StreamingMarkdownView(
)
}

// Animate frontier alpha: snap to 0 on new text, then animate to 1
val frontierAlpha = remember(itemId) { Animatable(1f) }

LaunchedEffect(text) {
frontierAlpha.snapTo(0f)
frontierAlpha.animateTo(1f, animationSpec = tween(durationMillis = 150))
onRendered?.invoke()
}

Expand All @@ -72,16 +65,16 @@ fun StreamingMarkdownView(
if (streamState.stableBlocks.isNotEmpty()) {
StreamingRenderBlocks(
blocks = streamState.stableBlocks,
alpha = 1f,
bodySize = bodySize,
)
}

// Render frontier blocks with fade-in
// Render frontier blocks at full opacity. Re-parsing the frontier is
// enough motion during streaming; restarting alpha on every token is
// the visible flicker.
if (streamState.frontierBlocks.isNotEmpty()) {
StreamingRenderBlocks(
blocks = streamState.frontierBlocks,
alpha = frontierAlpha.value,
bodySize = bodySize,
)
}
Expand All @@ -91,16 +84,14 @@ fun StreamingMarkdownView(
@Composable
private fun StreamingRenderBlocks(
blocks: List<AppMessageRenderBlock>,
alpha: Float,
bodySize: Float,
) {
blocks.forEachIndexed { index, block ->
blocks.forEach { block ->
when (block) {
is AppMessageRenderBlock.Markdown -> {
if (block.markdown.isNotEmpty()) {
StreamingMarkdownText(
text = block.markdown,
modifier = Modifier.alpha(alpha),
bodySize = bodySize,
)
}
Expand All @@ -109,14 +100,12 @@ private fun StreamingRenderBlocks(
if (isMathLanguage(block.language)) {
StreamingMarkdownText(
text = mathMarkdownBlock(block.code),
modifier = Modifier.alpha(alpha),
bodySize = bodySize,
)
} else {
StreamingCodeBlock(
language = block.language,
code = block.code,
modifier = Modifier.alpha(alpha),
bodySize = bodySize,
)
}
Expand All @@ -130,7 +119,6 @@ private fun StreamingRenderBlocks(
.build(),
contentDescription = "Assistant image",
modifier = Modifier
.alpha(alpha)
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(10.dp)),
Expand All @@ -151,6 +139,7 @@ private fun StreamingMarkdownText(
modifier = modifier.fillMaxWidth(),
bodySize = bodySize,
usePhysicalDpTextSize = true,
selectable = false,
)
}

Expand Down
Loading