diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index e288fc8..c041970 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -9,6 +9,7 @@ import iris.data.SecureStore import iris.ui.theme.Backdrop import iris.ui.theme.BackgroundMode import iris.ui.theme.UserTheme +import iris.util.STREAM_SMOOTHNESS_DEFAULT import java.util.UUID /** @@ -134,6 +135,10 @@ class AndroidSecureStore( get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true) 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 get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true) 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_TOOL_DETAIL = "tool_detail" 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_USER_BUBBLE_COLOR = "user_bubble_color" const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color" diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index bf11419..be9e63a 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -54,6 +54,10 @@ interface SecureStore { /** UI setting: stream assistant replies live (token by token). */ 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. */ var reasoningAutoCollapse: Boolean diff --git a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt index e83d5a7..60409fe 100644 --- a/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt +++ b/app/shared/src/commonMain/kotlin/iris/state/IrisController.kt @@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop import iris.ui.theme.BackgroundMode import iris.ui.theme.UserTheme import iris.util.IrisLog +import iris.util.STREAM_SMOOTHNESS_MAX +import iris.util.STREAM_SMOOTHNESS_MIN import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -267,6 +269,21 @@ class IrisController( 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 = _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") ─────────────────── // Persisted. When on, long reasoning blocks start collapsed (short ones // stay expanded); when off, all reasoning blocks start expanded. diff --git a/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt b/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt index a3dc6a1..7e28a53 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt @@ -9,17 +9,21 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkInteractionListener -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles 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.markdownColor import com.mikepenz.markdown.m3.markdownTypography +import com.mikepenz.markdown.model.StreamingMarkdownState import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.rememberMarkdownState +import com.mikepenz.markdown.model.rememberStreamingMarkdownState import com.mikepenz.markdown.utils.getUnescapedTextInNode import dev.snipme.highlights.Highlights import dev.snipme.highlights.model.SyntaxThemes 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.launch 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 @@ -53,6 +70,12 @@ import org.intellij.markdown.MarkdownElementTypes * syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the * raw markdown; [color] and [fontSize] match the surrounding bubble so the * 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 fun MarkdownText( @@ -64,13 +87,32 @@ fun MarkdownText( // plain highlighted code — the artifact card (and its runnable preview) // only appears once the message is complete. 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 // (isSystemInDarkTheme() is unreliable on desktop). - val highlights = remember { - Highlights.Builder().theme(SyntaxThemes.default(darkMode = true)) - } + val highlights = + remember { + Highlights.Builder().theme(SyntaxThemes.default(darkMode = true)) + } val base = TextStyle(color = color, fontSize = fontSize) val inlineCodeBackground = Color.White.copy(alpha = 0.08f) // Inline-code span style (mirrors the library's codeSpanStyle): the @@ -80,7 +122,8 @@ fun MarkdownText( // Brief green flash shown on the chip right after a copy, so the tap is // visible feedback (the clipboard write itself is silent). 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() val clipboard = LocalClipboardManager.current val scope = rememberCoroutineScope() @@ -92,57 +135,74 @@ fun MarkdownText( // 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 // LinkAnnotation carries the click listener; the URL is never opened. - val annotator = markdownAnnotator( - annotate = { content, child -> - if (child.type == MarkdownElementTypes.CODE_SPAN) { - val children = child.children - // 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 code = inner.joinToString("") { it.getUnescapedTextInNode(content) } - val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle - pushStyle(spanStyle) - withLink( - LinkAnnotation.Url( - url = "iris:copy-code", - // Keep every interaction state identical to the chip so - // hover/press never restyles the inline code. - styles = TextLinkStyles( - style = spanStyle, - focusedStyle = spanStyle, - hoveredStyle = spanStyle, - pressedStyle = spanStyle, - ), - linkInteractionListener = LinkInteractionListener { - clipboard.setText(AnnotatedString(code)) - copiedCode.value = code - scope.launch { - delay(600) - copiedCode.value = null - } - }, - ), - ) { - append(code) + val annotator = + markdownAnnotator( + annotate = { content, child -> + when { + child.type == MarkdownElementTypes.CODE_SPAN -> { + val children = child.children + // 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 code = inner.joinToString("") { it.getUnescapedTextInNode(content) } + val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle + pushStyle(spanStyle) + withLink( + LinkAnnotation.Url( + url = "iris:copy-code", + // Keep every interaction state identical to the chip so + // hover/press never restyles the inline code. + styles = + TextLinkStyles( + style = spanStyle, + focusedStyle = spanStyle, + hoveredStyle = spanStyle, + pressedStyle = spanStyle, + ), + linkInteractionListener = + LinkInteractionListener { + clipboard.setText(AnnotatedString(code)) + copiedCode.value = code + scope.launch { + delay(600) + copiedCode.value = null + } + }, + ), + ) { + append(code) + } + pop() + true + } + + // 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 + } } - pop() - true - } else { - false - } - }, - ) - Markdown( - markdownState = state, - modifier = modifier, - annotator = annotator, - colors = markdownColor( + }, + ) + val colors = + markdownColor( text = color, codeBackground = Color.Black.copy(alpha = 0.8f), inlineCodeBackground = inlineCodeBackground, dividerColor = IrisColors.divider, tableBackground = Color.White.copy(alpha = 0.03f), - ), - typography = markdownTypography( + ) + val typography = + markdownTypography( h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold), h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold), h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold), @@ -157,15 +217,17 @@ fun MarkdownText( ordered = base, bullet = base, list = base, - textLink = TextLinkStyles( - style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), - ), + textLink = + TextLinkStyles( + style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), + ), table = base.copy(fontSize = fontSize * 0.95f), - ), - components = markdownComponents( + ) + val components = + markdownComponents( codeFence = { model -> 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) } else { CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) @@ -174,7 +236,7 @@ fun MarkdownText( }, codeBlock = { model -> 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) } else { 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 // Material 3 checkbox instead. 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 * (Telegram-style). Tapping the button copies the whole block to the clipboard. @@ -213,12 +387,13 @@ internal fun CodeBlockWithCopy( if (code.isNotBlank()) { IconButton( onClick = { clipboard.setText(AnnotatedString(code)) }, - modifier = Modifier - .align(Alignment.TopEnd) - // MarkdownHighlightedCode insets its background by 8dp top; - // match that so the button sits inside the block, not above it. - .padding(top = 8.dp, end = 4.dp) - .size(28.dp), + modifier = + Modifier + .align(Alignment.TopEnd) + // MarkdownHighlightedCode insets its background by 8dp top; + // match that so the button sits inside the block, not above it. + .padding(top = 8.dp, end = 4.dp) + .size(28.dp), ) { Icon( imageVector = Icons.Filled.ContentCopy, @@ -228,4 +403,4 @@ internal fun CodeBlockWithCopy( } } } -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 2a66f80..fe49bac 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -143,12 +143,11 @@ import iris.ui.theme.IrisColors import iris.ui.theme.LocalUserTheme import iris.ui.theme.avatarColor import iris.ui.theme.contrastText +import iris.util.STREAM_SMOOTHNESS_DEFAULT import iris.util.formatDayLabel import iris.util.formatTime import iris.util.fuzzyScore import iris.util.localDayKey -import iris.util.prepareForMarkdown -import iris.util.preserveNewlinesAsHardBreaks import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.Locale @@ -171,6 +170,7 @@ fun ChatScreen(controller: IrisController) { val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() + val streamSmoothness by controller.streamSmoothness.collectAsState() val channels by controller.channels.channels.collectAsState() val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane) @@ -712,6 +712,7 @@ fun ChatScreen(controller: IrisController) { }, runtimeFooterEnabled = runtimeFooterEnabled, runtimeFooterFields = runtimeFooterFields, + streamSmoothness = streamSmoothness, ) } } @@ -2521,6 +2522,7 @@ private fun MessageBubble( onSelect: (() -> Unit)? = null, runtimeFooterEnabled: Boolean = false, runtimeFooterFields: List = emptyList(), + streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT, ) { val isUser = msg.role == ROLE_USER val isCommentary = msg.isCommentary @@ -2594,9 +2596,7 @@ private fun MessageBubble( // in replies. if (msg.text.isNotBlank() || msg.streaming) { MarkdownText( - text = - msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + - if (msg.streaming) " ▉" else "", + text = msg.text, color = textColor, fontSize = 15.sp, isStreaming = msg.streaming, @@ -2641,20 +2641,18 @@ private fun MessageBubble( } else { if (msg.text.isNotBlank() || msg.streaming) { // M8: render the agent's reply as markdown (bold / italic / - // underscore, tables, highlighted code blocks). Leading - // newlines are stripped so the text hugs the top of the - // bubble; single newlines become hard breaks (models write - // status lines and wrapped text expecting a break per line, - // same as user input); the ▉ cursor is kept while streaming. - val displayText = - msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + - if (msg.streaming) " ▉" else "" + // underscore, tables, highlighted code blocks). The + // transform (leading-newline strip, hard breaks) and the ▉ + // streaming cursor live inside MarkdownText; while + // streaming the text is revealed at a steady rate and + // parsed incrementally (docs/05 §5.1). MarkdownText( - text = displayText, + text = msg.text, color = textColor, fontSize = if (isCommentary) 13.sp else 15.sp, modifier = Modifier.fillMaxWidth(), isStreaming = msg.streaming, + streamSmoothness = streamSmoothness, ) } } diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt index e39bcc4..379c571 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/SettingsScreen.kt @@ -54,6 +54,8 @@ import iris.ui.theme.Backdrop import iris.ui.theme.BackgroundMode import iris.ui.theme.IrisColors import iris.ui.theme.LocalUserTheme +import iris.util.STREAM_SMOOTHNESS_MAX +import iris.util.STREAM_SMOOTHNESS_MIN import kotlin.math.roundToInt /** @@ -71,6 +73,7 @@ fun SettingsScreen( ) { val threadsEnabled by controller.threadsEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState() + val streamSmoothness by controller.streamSmoothness.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() @@ -142,6 +145,27 @@ fun SettingsScreen( 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 { Row( diff --git a/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt b/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt index cc9f53b..87d4589 100644 --- a/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt +++ b/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt @@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String { fun String.preserveNewlinesAsHardBreaks(): String { val lines = split("\n") var inCodeBlock = false - val out = lines.map { line -> - val trimmed = line.trimStart() - when { - trimmed.startsWith("```") || trimmed.startsWith("~~~") -> { - inCodeBlock = !inCodeBlock - line + val out = + lines.map { line -> + val trimmed = line.trimStart() + when { + trimmed.startsWith("```") || trimmed.startsWith("~~~") -> { + inCodeBlock = !inCodeBlock + line + } + + inCodeBlock || line.isBlank() -> { + line + } + + else -> { + line + " " + } } - inCodeBlock || line.isBlank() -> line - else -> line + " " } - } return out.joinToString("\n") -} \ No newline at end of file +} + +/** + * 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) diff --git a/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt b/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt index c0df4e2..bb5b7e3 100644 --- a/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt +++ b/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt @@ -2,9 +2,9 @@ package iris.util import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class MarkdownTest { - @Test fun stripsLeadingNewlines() { assertEquals("Hello", "\n\nHello".prepareForMarkdown()) @@ -61,4 +61,90 @@ class MarkdownTest { val md = "**Title:** x\n**Model:** y" assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks()) } -} \ No newline at end of file + + @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) + } +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index e315d2a..580be06 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -5,6 +5,7 @@ import iris.protocol.IrisJson import iris.ui.theme.Backdrop import iris.ui.theme.BackgroundMode import iris.ui.theme.UserTheme +import iris.util.STREAM_SMOOTHNESS_DEFAULT import kotlinx.serialization.Serializable import java.io.File import java.security.SecureRandom @@ -64,6 +65,7 @@ class DesktopSecureStore : SecureStore { val threadsEnabled: Boolean = false, val toolDetail: String = "truncated", val streamingEnabled: Boolean = true, + val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT, val reasoningAutoCollapse: Boolean = true, val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, @@ -222,6 +224,13 @@ class DesktopSecureStore : SecureStore { 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 get() = load().reasoningAutoCollapse set(value) { diff --git a/docs/05-streaming.md b/docs/05-streaming.md index 004c7ce..773ce72 100644 --- a/docs/05-streaming.md +++ b/docs/05-streaming.md @@ -12,11 +12,13 @@ produced by the gateway and rendered by the app. **Gateway side.** The agent's `stream_delta_callback` feeds a `GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer accumulates text and, at intervals / thresholds, calls: + - `adapter.send(chat_id, text)` — first time a bubble is created. - `adapter.edit_message(chat_id, message_id, text)` — subsequent updates (each carries the **full** accumulated text). **Adapter → frames.** + - First `send()` of a turn segment → `message.start {message_id, role}`. - Each `edit_message()` → `message.update {message_id, text}` (full text). - 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 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: + - **Gateway side:** hermes `display.platforms.iris.streaming` (default follows global). When off, the app just gets one final `message` frame. - **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 `show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and chosen by `reasoning_style` (`gateway/display_config.py:37`): + - `code` (default): `💭 **Reasoning:**\n```\n\n```\n\n` - `blockquote`: `> 💭 **Reasoning:**\n> …\n\n` - `subtext`: `-# 💭 Reasoning\n-# …\n\n` (Discord-style) **Plugin config.** Set for the `iris` platform: + ```yaml display: platforms: @@ -59,12 +100,14 @@ display: ``` **Adapter split.** In `send()`, detect the `code`-style prefix and split: + ``` prefix = "💭 **Reasoning:**\n```\n" # find the closing "\n```\n\n" after the prefix reasoning = text[len(prefix):close_idx] body = text[close_idx + len("\n```\n\n"):] ``` + Emit `message {reasoning: , text: , …}`. If no prefix is found (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 + line format) and emits **structured** frames — not pre-formatted strings: + - `tool.start {index, name, preview, args}` — a tool call began. - `tool.progress {index, name, note}` — in-progress update (optional). - `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). **App side — the verbosity setting** (Settings → "Tool detail"): + - **Everything** — show tool name, full args (collapsible), and output preview. - **Truncated** (default) — show `emoji name: "short preview"` one-liner, collapsible to expand. @@ -147,4 +192,4 @@ srv → message.start {message_id:m3} srv → message.update {m3, "The repo has…"} srv → message.stop {m3, final_text:"…", reasoning:"…", model:"…", tokens:42} srv → typing {on:false} -``` \ No newline at end of file +```