app: smooth streaming markdown via incremental parser + typewriter reveal
CI / Gateway plugin tests (push) Successful in 6m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m36s

- MarkdownText: streaming branch on the library's StreamingMarkdownState
  (rememberStreamingMarkdownPipeline) — incremental append, no full
  re-parse per snapshot; static path keeps retainState=true
- client-side reveal: buffer snapshots, reveal at a steady rate
  (streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative
  catch-up (jump when backlog > 2s of reveal time)
- keep the streaming renderer alive after message.stop until the reveal
  catches up (useStreaming gate); artifact card waits for the same
- cursor drawn via annotator, never part of the parse input
- new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per
  device via SecureStore, live-updatable
- docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
This commit is contained in:
ARIA committed 2026-08-25 11:00:22 +02:00
1 parent c7a16d51e3
commit 83a67f6fb1
10 files changed
+502 -47

No files matched your search

@@ -9,6 +9,7 @@ import iris.data.SecureStore
import iris.ui.theme.Backdrop import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import java.util.UUID import java.util.UUID
/** /**
@@ -134,6 +135,10 @@ class AndroidSecureStore(
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true) get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply() set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var streamSmoothness: Float
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
override var reasoningAutoCollapse: Boolean override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true) get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply() set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
@@ -225,6 +230,7 @@ class AndroidSecureStore(
const val KEY_THREADS_ENABLED = "threads_enabled" const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled" const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse" const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color" const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color" const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
@@ -54,6 +54,10 @@ interface SecureStore {
/** UI setting: stream assistant replies live (token by token). */ /** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean var streamingEnabled: Boolean
/** UI setting: streaming smoothness — seconds between visible text
* updates while streaming (0.2–0.8; lower = faster/smoother). */
var streamSmoothness: Float
/** UI setting: auto-collapse long reasoning blocks in the chat view. */ /** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean var reasoningAutoCollapse: Boolean
@@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.IrisLog import iris.util.IrisLog
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -267,6 +269,21 @@ class IrisController(
chat.streamingEnabled = _streamingEnabled.value chat.streamingEnabled = _streamingEnabled.value
} }
// Streaming smoothness (Settings → "Streaming speed"): seconds between
// visible text updates while streaming (lower = faster/smoother).
// Per-device display preference; applied on the fly by the reveal loop
// in MarkdownText (docs/05 §5.1).
private val _streamSmoothness =
MutableStateFlow(store.streamSmoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX))
val streamSmoothness: StateFlow<Float> = _streamSmoothness.asStateFlow()
fun setStreamSmoothness(value: Float) {
val clamped = value.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
if (clamped == _streamSmoothness.value) return
_streamSmoothness.value = clamped
store.streamSmoothness = clamped
}
// ── Reasoning auto-collapse (Settings → "Reasoning") ─────────────────── // ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
// Persisted. When on, long reasoning blocks start collapsed (short ones // Persisted. When on, long reasoning blocks start collapsed (short ones
// stay expanded); when off, all reasoning blocks start expanded. // stay expanded); when off, all reasoning blocks start expanded.
@@ -9,17 +9,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -37,15 +41,28 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.StreamingMarkdownState
import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.appendChunk
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import iris.util.preserveNewlinesAsHardBreaksStreaming
import iris.util.revealStep
import iris.util.streamCharsPerSecond
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
private const val STREAM_REVEAL_TICK_MS = 50L
/** /**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM * Renders a chat message as markdown (M8): bold / italic / underscore, GFM
@@ -53,6 +70,12 @@ import org.intellij.markdown.MarkdownElementTypes
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the * syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the * raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in. * rendered text blends in.
*
* While [isStreaming], the text is revealed at a steady rate (controlled by
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
* blocks are parsed once and never re-laid out, only the tail re-renders per
* tick — so the bubble stays readable instead of reflowing (and flashing raw
* markdown) on every gateway update.
*/ */
@Composable @Composable
fun MarkdownText( fun MarkdownText(
@@ -64,11 +87,30 @@ fun MarkdownText(
// plain highlighted code — the artifact card (and its runnable preview) // plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete. // only appears once the message is complete.
isStreaming: Boolean = false, isStreaming: Boolean = false,
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) { ) {
val state = rememberMarkdownState(text) // Static path: transform once per text change (leading blanks stripped,
// single newlines → hard breaks).
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
// Whether this bubble has ever been streamed. Static history messages
// skip the reveal pipeline entirely.
val hasStreamed = remember { mutableStateOf(isStreaming) }
if (isStreaming) hasStreamed.value = true
val pipeline =
rememberStreamingMarkdownPipeline(
text = text,
smoothness = streamSmoothness,
active = hasStreamed.value,
streaming = isStreaming,
)
// Keep the streaming renderer until the reveal catches up, even after
// message.stop — a fast model that dumps the whole text at once still
// plays out the typewriter instead of jumping to the full message.
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
// The app is always dark-themed, so force the dark highlight palette // The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop). // (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember { val highlights =
remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true)) Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
} }
val base = TextStyle(color = color, fontSize = fontSize) val base = TextStyle(color = color, fontSize = fontSize)
@@ -80,7 +122,8 @@ fun MarkdownText(
// Brief green flash shown on the chip right after a copy, so the tap is // Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent). // visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle = val copiedCodeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f)) base
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle() .toSpanStyle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -92,9 +135,11 @@ fun MarkdownText(
// Re-render it without the padding, and wrap it in a link so tapping the // Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The // inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened. // LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator( val annotator =
markdownAnnotator(
annotate = { content, child -> annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) { when {
child.type == MarkdownElementTypes.CODE_SPAN -> {
val children = child.children val children = child.children
// Drop the surrounding backtick tokens (present as first/last child). // Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
@@ -106,13 +151,15 @@ fun MarkdownText(
url = "iris:copy-code", url = "iris:copy-code",
// Keep every interaction state identical to the chip so // Keep every interaction state identical to the chip so
// hover/press never restyles the inline code. // hover/press never restyles the inline code.
styles = TextLinkStyles( styles =
TextLinkStyles(
style = spanStyle, style = spanStyle,
focusedStyle = spanStyle, focusedStyle = spanStyle,
hoveredStyle = spanStyle, hoveredStyle = spanStyle,
pressedStyle = spanStyle, pressedStyle = spanStyle,
), ),
linkInteractionListener = LinkInteractionListener { linkInteractionListener =
LinkInteractionListener {
clipboard.setText(AnnotatedString(code)) clipboard.setText(AnnotatedString(code))
copiedCode.value = code copiedCode.value = code
scope.launch { scope.launch {
@@ -126,23 +173,36 @@ fun MarkdownText(
} }
pop() pop()
true true
} else { }
// Streaming cursor: append ▉ to the last text leaf of the
// document so the cursor sits at the end of the visible text.
// The tail is re-annotated on every reveal tick, so the cursor
// follows the text (and vanishes once the message is final).
useStreaming &&
child.type == MarkdownTokenTypes.TEXT &&
content.length - child.endOffset <= 1 -> {
append(child.getUnescapedTextInNode(content))
append(" ▉")
true
}
else -> {
false false
} }
}
}, },
) )
Markdown( val colors =
markdownState = state, markdownColor(
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color, text = color,
codeBackground = Color.Black.copy(alpha = 0.8f), codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground, inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider, dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f), tableBackground = Color.White.copy(alpha = 0.03f),
), )
typography = markdownTypography( val typography =
markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold), h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold), h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold), h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
@@ -157,15 +217,17 @@ fun MarkdownText(
ordered = base, ordered = base,
bullet = base, bullet = base,
list = base, list = base,
textLink = TextLinkStyles( textLink =
TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
), ),
table = base.copy(fontSize = fontSize * 0.95f), table = base.copy(fontSize = fontSize * 0.95f),
), )
components = markdownComponents( val components =
markdownComponents(
codeFence = { model -> codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style -> MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) { if (!useStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -174,7 +236,7 @@ fun MarkdownText(
}, },
codeBlock = { model -> codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style -> MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) { if (!useStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -184,17 +246,129 @@ fun MarkdownText(
// The core default checkbox renders literal "[x]"/"[ ]" text; use the // The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead. // Material 3 checkbox instead.
checkbox = { checkbox = {
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text) com.mikepenz.markdown.m3.elements
}, .MarkdownCheckBox(it.content, it.node, it.typography.text)
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
}, },
) )
if (useStreaming) {
if (pipeline.displayed.isEmpty()) {
// No text revealed yet (message.start, first tick pending): show
// just the cursor so the bubble is visible immediately.
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
} else {
Markdown(
streamingMarkdownState = pipeline.state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
)
}
} else {
// retainState: keep the last formatted output visible while the new
// text re-parses (async) — no raw-markdown flash between updates.
val state = rememberMarkdownState(displayText, retainState = true)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
loading = { m ->
// First parse of a (long) message: show the text so the bubble
// never goes blank.
Text(displayText, modifier = m, color = color, fontSize = fontSize)
},
)
}
} }
/**
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
* snapshot from the gateway; it is revealed at a steady rate controlled by
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
* keep their AST identity, so Compose never re-lays them out — only the
* unstable tail re-renders per tick.
*
* [active] is false for history messages (never streamed): the reveal is
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
* indicates the message is still receiving updates; once the reveal catches
* up and the message is final, the reveal loop stops.
*
* Returns the parser state, the currently revealed text (read inside the
* composition so reveal ticks recompose the caller), and whether the reveal
* has caught up with the target.
*/
@Composable
private fun rememberStreamingMarkdownPipeline(
text: String,
smoothness: Float,
active: Boolean,
streaming: Boolean,
): StreamingPipeline {
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
// a prefix of the revealed text always maps to a prefix of the target, so
// the diff between consecutive reveals is a pure append.
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
val displayed = remember { mutableStateOf("") }
val latestTarget = rememberUpdatedState(target)
val latestSmoothness = rememberUpdatedState(smoothness)
val latestStreaming = rememberUpdatedState(streaming)
// Bumped when the target is rewritten (not extended): the parser state is
// recreated via key() below and re-seeded with the full text.
val generation = remember { mutableIntStateOf(0) }
val state = key(generation.value) { rememberStreamingMarkdownState() }
LaunchedEffect(state, active) {
if (!active) {
// Never streamed (history message): reveal everything at once so
// the caller falls back to the static renderer immediately.
displayed.value = latestTarget.value
return@LaunchedEffect
}
var last = ""
while (true) {
delay(STREAM_REVEAL_TICK_MS)
val t = latestTarget.value
val cur = displayed.value
if (cur == t) {
// Reveal complete: keep ticking only while the message is
// still streaming (more text may arrive); otherwise stop so
// finished bubbles don't burn battery.
if (!latestStreaming.value) return@LaunchedEffect
continue
}
val step =
revealStep(
remaining = t.length - cur.length,
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
)
val next = t.take(cur.length + step)
displayed.value = next
val chunk = appendChunk(last, next)
if (chunk == null) {
// Rewritten, not extended: recreate the parser state. The
// effect restarts (keyed on [state]) and re-seeds it.
generation.value++
return@LaunchedEffect
}
if (chunk.isNotEmpty()) state.append(chunk)
last = next
}
}
return StreamingPipeline(state, displayed.value, displayed.value == target)
}
/** Result of the streaming reveal pipeline. */
private data class StreamingPipeline(
val state: StreamingMarkdownState,
val displayed: String,
val done: Boolean,
)
/** /**
* Renders a highlighted code block with a copy button in the top-right corner * Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard. * (Telegram-style). Tapping the button copies the whole block to the clipboard.
@@ -213,7 +387,8 @@ internal fun CodeBlockWithCopy(
if (code.isNotBlank()) { if (code.isNotBlank()) {
IconButton( IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) }, onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier modifier =
Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top; // MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it. // match that so the button sits inside the block, not above it.
@@ -143,12 +143,11 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.ui.theme.avatarColor import iris.ui.theme.avatarColor
import iris.ui.theme.contrastText import iris.ui.theme.contrastText
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.formatDayLabel import iris.util.formatDayLabel
import iris.util.formatTime import iris.util.formatTime
import iris.util.fuzzyScore import iris.util.fuzzyScore
import iris.util.localDayKey import iris.util.localDayKey
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@@ -171,6 +170,7 @@ fun ChatScreen(controller: IrisController) {
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState() val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val channels by controller.channels.channels.collectAsState() val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane) val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
@@ -712,6 +712,7 @@ fun ChatScreen(controller: IrisController) {
}, },
runtimeFooterEnabled = runtimeFooterEnabled, runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields, runtimeFooterFields = runtimeFooterFields,
streamSmoothness = streamSmoothness,
) )
} }
} }
@@ -2521,6 +2522,7 @@ private fun MessageBubble(
onSelect: (() -> Unit)? = null, onSelect: (() -> Unit)? = null,
runtimeFooterEnabled: Boolean = false, runtimeFooterEnabled: Boolean = false,
runtimeFooterFields: List<String> = emptyList(), runtimeFooterFields: List<String> = emptyList(),
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) { ) {
val isUser = msg.role == ROLE_USER val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary val isCommentary = msg.isCommentary
@@ -2594,9 +2596,7 @@ private fun MessageBubble(
// in replies. // in replies.
if (msg.text.isNotBlank() || msg.streaming) { if (msg.text.isNotBlank() || msg.streaming) {
MarkdownText( MarkdownText(
text = text = msg.text,
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else "",
color = textColor, color = textColor,
fontSize = 15.sp, fontSize = 15.sp,
isStreaming = msg.streaming, isStreaming = msg.streaming,
@@ -2641,20 +2641,18 @@ private fun MessageBubble(
} else { } else {
if (msg.text.isNotBlank() || msg.streaming) { if (msg.text.isNotBlank() || msg.streaming) {
// M8: render the agent's reply as markdown (bold / italic / // M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading // underscore, tables, highlighted code blocks). The
// newlines are stripped so the text hugs the top of the // transform (leading-newline strip, hard breaks) and the ▉
// bubble; single newlines become hard breaks (models write // streaming cursor live inside MarkdownText; while
// status lines and wrapped text expecting a break per line, // streaming the text is revealed at a steady rate and
// same as user input); the ▉ cursor is kept while streaming. // parsed incrementally (docs/05 §5.1).
val displayText =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else ""
MarkdownText( MarkdownText(
text = displayText, text = msg.text,
color = textColor, color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp, fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming, isStreaming = msg.streaming,
streamSmoothness = streamSmoothness,
) )
} }
} }
@@ -54,6 +54,8 @@ import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -71,6 +73,7 @@ fun SettingsScreen(
) { ) {
val threadsEnabled by controller.threadsEnabled.collectAsState() val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState() val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
@@ -142,6 +145,27 @@ fun SettingsScreen(
onCheckedChange = { controller.toggleStreaming() }, onCheckedChange = { controller.toggleStreaming() },
) )
} }
if (streamingEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
Text(
"How fast new text appears while streaming — lower is faster",
fontSize = 11.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
Slider(
value = streamSmoothness,
onValueChange = { controller.setStreamSmoothness(it) },
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
modifier = Modifier.weight(1f),
)
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
}
}
} }
SettingsCard { SettingsCard {
Row( Row(
@@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String {
fun String.preserveNewlinesAsHardBreaks(): String { fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n") val lines = split("\n")
var inCodeBlock = false var inCodeBlock = false
val out = lines.map { line -> val out =
lines.map { line ->
val trimmed = line.trimStart() val trimmed = line.trimStart()
when { when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> { trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock inCodeBlock = !inCodeBlock
line line
} }
inCodeBlock || line.isBlank() -> line
else -> line + " " inCodeBlock || line.isBlank() -> {
line
}
else -> {
line + " "
}
} }
} }
return out.joinToString("\n") return out.joinToString("\n")
} }
/**
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
*
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
* renderer feed the diff between consecutive snapshots into an append-only
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
* invisible at the end of the document, so a finished message renders exactly
* like the static transform.
*/
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
val lines = split("\n")
var inCodeBlock = false
val out =
lines.mapIndexed { index, line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> {
line
}
index == lines.lastIndex -> {
line
}
// still being typed
else -> {
line + " "
}
}
}
return out.joinToString("\n")
}
/**
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
* content was rewritten, not appended. The gateway sends full text snapshots
* on every `message.update`; this converts them into append chunks for the
* append-only streaming parser.
*/
fun appendChunk(
last: String,
next: String,
): String? = if (next.startsWith(last)) next.substring(last.length) else null
/**
* Number of characters to reveal on one streaming tick. The reveal rate comes
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
* (fast model, reconnect catch-up) everything is revealed at once so the
* display never lags far behind the received text.
*/
fun revealStep(
remaining: Int,
charsPerSecond: Double,
tickSeconds: Double,
): Int {
if (remaining <= 0) return 0
if (remaining > charsPerSecond * 2.0) return remaining
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
}
/**
* Range of the streaming-smoothness setting (seconds between visible text
* updates while streaming; lower = faster/smoother).
*/
const val STREAM_SMOOTHNESS_MIN = 0.2f
const val STREAM_SMOOTHNESS_MAX = 0.8f
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
/**
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
*/
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
@@ -2,9 +2,9 @@ package iris.util
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MarkdownTest { class MarkdownTest {
@Test @Test
fun stripsLeadingNewlines() { fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown()) assertEquals("Hello", "\n\nHello".prepareForMarkdown())
@@ -61,4 +61,90 @@ class MarkdownTest {
val md = "**Title:** x\n**Model:** y" val md = "**Title:** x\n**Model:** y"
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks()) assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
} }
@Test
fun streamingTransformSkipsLastLineOnly() {
// Identical to the static transform except the (still-incomplete)
// last line gets no trailing hard-break spaces.
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
val md = "```kotlin\nval a = 1\nval b = 2\n```"
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformIsPrefixPreserving() {
// The core invariant the incremental renderer relies on: for every
// prefix p of s, transform(p) is a prefix of transform(s).
val docs =
listOf(
"a\nb",
"a\n\nb\nc",
"**bold** and `code`",
"```kotlin\nval a = 1\n```\nthen text",
"~~~\nx\n~~~\ny",
"| a | b |\n| - | - |\n| 1 | 2 |",
"- item\n- item2",
"line with trailing spaces \nnext",
"",
"\n\n \n",
)
for (s in docs) {
val whole = s.preserveNewlinesAsHardBreaksStreaming()
for (i in 0..s.length) {
val p = s.take(i)
val tp = p.preserveNewlinesAsHardBreaksStreaming()
assertTrue(
whole.startsWith(tp),
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
)
}
}
}
@Test
fun appendChunkReturnsSuffixOnExtension() {
assertEquals(" world", appendChunk("Hello", "Hello world"))
assertEquals("", appendChunk("abc", "abc"))
assertEquals("abc", appendChunk("", "abc"))
}
@Test
fun appendChunkReturnsNullOnRewrite() {
assertEquals(null, appendChunk("Hello", "Hi"))
assertEquals(null, appendChunk("Hello world", "Hello"))
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
}
@Test
fun revealStepRevealsAtLeastOneChar() {
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
}
@Test
fun revealStepCatchesUpWhenBacklogIsLarge() {
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
}
@Test
fun streamCharsPerSecondMapsSmoothnessRange() {
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
// values are clamped to the ends.
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
}
} }
@@ -5,6 +5,7 @@ import iris.protocol.IrisJson
import iris.ui.theme.Backdrop import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
@@ -64,6 +65,7 @@ class DesktopSecureStore : SecureStore {
val threadsEnabled: Boolean = false, val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated", val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true, val streamingEnabled: Boolean = true,
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
val reasoningAutoCollapse: Boolean = true, val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
@@ -222,6 +224,13 @@ class DesktopSecureStore : SecureStore {
save(d.copy(streamingEnabled = value)) save(d.copy(streamingEnabled = value))
} }
override var streamSmoothness: Float
get() = load().streamSmoothness
set(value) {
val d = load()
save(d.copy(streamSmoothness = value))
}
override var reasoningAutoCollapse: Boolean override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse get() = load().reasoningAutoCollapse
set(value) { set(value) {
+45
View File
@@ -12,11 +12,13 @@ produced by the gateway and rendered by the app.
**Gateway side.** The agent's `stream_delta_callback` feeds a **Gateway side.** The agent's `stream_delta_callback` feeds a
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer `GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
accumulates text and, at intervals / thresholds, calls: accumulates text and, at intervals / thresholds, calls:
- `adapter.send(chat_id, text)` — first time a bubble is created. - `adapter.send(chat_id, text)` — first time a bubble is created.
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates - `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
(each carries the **full** accumulated text). (each carries the **full** accumulated text).
**Adapter → frames.** **Adapter → frames.**
- First `send()` of a turn segment → `message.start {message_id, role}`. - First `send()` of a turn segment → `message.start {message_id, role}`.
- Each `edit_message()` → `message.update {message_id, text}` (full text). - Each `edit_message()` → `message.update {message_id, text}` (full text).
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?, - Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
@@ -27,7 +29,44 @@ replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom. while the user is at the bottom.
**Smooth streaming (app-side rendering).** Re-parsing + re-laying-out the
whole bubble on every update made streamed text unreadable (raw-markdown
flashes, constant reflow). `MarkdownText` therefore renders streaming bubbles
incrementally:
- **Reveal (typewriter):** the latest full snapshot is revealed at a steady
rate instead of jumping per gateway update. The rate is user-adjustable:
Settings → "Streaming speed" (`streamSmoothness`, 0.2–0.8 s between visible
updates, lower = faster; `streamCharsPerSecond` maps it to chars/s:
`60 / smoothness`, so 0.2 → 300 chars/s, 0.8 → 75 chars/s). Applied on the
fly; a backlog exceeding ~2 s of reveal time is revealed at once (fast
model / reconnect catch-up).
- **Wait for the reveal:** the streaming renderer stays active until the
reveal catches up, even after `message.stop` — a fast model that dumps the
whole text in one or two frames still plays out the typewriter instead of
jumping to the full message. Only then does the bubble switch to the static
renderer (which also enables the HTML artifact card).
- **Incremental parse:** the revealed prefix is fed as append chunks
(`appendChunk`) into the library's `StreamingMarkdownState`
(`rememberStreamingMarkdownState`, mikepenz 0.44.0) — an append-only parser
that re-parses only the unstable tail. Settled blocks keep AST identity, so
Compose never re-lays them out; only the tail re-renders per tick. A
non-extension snapshot (rewrite) recreates the parser state and re-seeds it.
- **Prefix-preserving transform:** `preserveNewlinesAsHardBreaksStreaming()`
is like `preserveNewlinesAsHardBreaks()` but skips the still-incomplete last
line, so the transform of a prefix is always a prefix of the transform of
the whole (required for pure-append diffs). The static path keeps the
original transform plus `retainState = true` (last formatted output stays
visible during re-parses — no raw flash).
- **Cursor:** the ▉ is appended to the last text leaf by the annotator (not to
the parse input, which would break the append diff).
The gateway cadence (`edit_interval` / `buffer_threshold`, default 0.8 s /
24 chars) is unchanged — smoothing happens entirely client-side, so it works
with any gateway and per device.
**Streaming on/off.** Two levels: **Streaming on/off.** Two levels:
- **Gateway side:** hermes `display.platforms.iris.streaming` (default - **Gateway side:** hermes `display.platforms.iris.streaming` (default
follows global). When off, the app just gets one final `message` frame. follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When - **App side (per device):** Settings → "Streaming" toggle (default on). When
@@ -45,11 +84,13 @@ reference screenshot's "Reasoning:" panel with a copy button).
**Gateway side.** hermes prepends reasoning to the final response when **Gateway side.** hermes prepends reasoning to the final response when
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and `show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
chosen by `reasoning_style` (`gateway/display_config.py:37`): chosen by `reasoning_style` (`gateway/display_config.py:37`):
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>` - `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>` - `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style) - `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `iris` platform: **Plugin config.** Set for the `iris` platform:
```yaml ```yaml
display: display:
platforms: platforms:
@@ -59,12 +100,14 @@ display:
``` ```
**Adapter split.** In `send()`, detect the `code`-style prefix and split: **Adapter split.** In `send()`, detect the `code`-style prefix and split:
``` ```
prefix = "💭 **Reasoning:**\n```\n" prefix = "💭 **Reasoning:**\n```\n"
# find the closing "\n```\n\n" after the prefix # find the closing "\n```\n\n" after the prefix
reasoning = text[len(prefix):close_idx] reasoning = text[len(prefix):close_idx]
body = text[close_idx + len("\n```\n\n"):] body = text[close_idx + len("\n```\n\n"):]
``` ```
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`. (reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
@@ -89,6 +132,7 @@ gateway progress queue → `send_progress_messages` (`gateway/run.py:4603`) →
**Adapter → frames.** The adapter classifies tool activity (via turn-state + **Adapter → frames.** The adapter classifies tool activity (via turn-state +
line format) and emits **structured** frames — not pre-formatted strings: line format) and emits **structured** frames — not pre-formatted strings:
- `tool.start {index, name, preview, args}` — a tool call began. - `tool.start {index, name, preview, args}` — a tool call began.
- `tool.progress {index, name, note}` — in-progress update (optional). - `tool.progress {index, name, note}` — in-progress update (optional).
- `tool.end {index, name, ok, duration, output_preview}` — completed. - `tool.end {index, name, ok, duration, output_preview}` — completed.
@@ -99,6 +143,7 @@ short tail; full tool output is **not** streamed (it lives in agent history and
is reachable via search). is reachable via search).
**App side — the verbosity setting** (Settings → "Tool detail"): **App side — the verbosity setting** (Settings → "Tool detail"):
- **Everything** — show tool name, full args (collapsible), and output preview. - **Everything** — show tool name, full args (collapsible), and output preview.
- **Truncated** (default) — show `emoji name: "short preview"` one-liner, - **Truncated** (default) — show `emoji name: "short preview"` one-liner,
collapsible to expand. collapsible to expand.