From f8effb41526a073ce461c54cddb2a2e04e75d2fa Mon Sep 17 00:00:00 2001 From: ARIA Date: Thu, 20 Aug 2026 23:29:43 +0200 Subject: [PATCH] Chat bubbles: render markdown (bold/italic/tables/syntax-highlighted code) for agent + user messages; tap-to-copy inline code with flash, code-block copy button; preserve user newlines; removed stale HANDOFF doc --- HANDOFF-gateway-restart.md | 172 -------------- app/shared/build.gradle.kts | 16 ++ .../commonMain/kotlin/iris/ui/MarkdownText.kt | 216 ++++++++++++++++++ .../kotlin/iris/ui/screens/ChatScreen.kt | 22 +- .../commonMain/kotlin/iris/util/Markdown.kt | 39 ++++ .../kotlin/iris/util/MarkdownTest.kt | 42 ++++ 6 files changed, 330 insertions(+), 177 deletions(-) delete mode 100644 HANDOFF-gateway-restart.md create mode 100644 app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt create mode 100644 app/shared/src/commonMain/kotlin/iris/util/Markdown.kt create mode 100644 app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt diff --git a/HANDOFF-gateway-restart.md b/HANDOFF-gateway-restart.md deleted file mode 100644 index 7625de2..0000000 --- a/HANDOFF-gateway-restart.md +++ /dev/null @@ -1,172 +0,0 @@ -# Hand-off: Gateway restart UX (spinner + "back online" message) - -> This document is a hand-off. A previous attempt at this task is **incomplete -> and leaves the chat in a broken state**. Read this fully before touching code. - -## 1. Original task (from the user) - -On **gateway restart** the Android/desktop app had two problems: - -1. A **spinner spins endlessly** (a tool card that never completes). -2. There is **no "gateway back online" message**. - -The user wants the standard hermes routine (same wording on all apps): - -``` -⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off. -``` -``` -♻️ Gateway online — Hermes is back and ready. -``` - -## 2. Architecture (critical context) - -Three layers, only two are editable: - -- **`hermes-agent/`** — READ-ONLY research reference (git-ignored, pre-commit - hook blocks commits). The hermes **core** lives here. Do NOT modify it. -- **`gateway-plugin/`** — the editable Python `android` platform plugin - (symlinked into `~/.hermes/plugins/android`). `adapter.py` is the outbound - frame classifier. This is where the gateway turns core `adapter.send()` calls - into WS frames. -- **`app/`** — Compose Multiplatform (Kotlin). `:shared` holds most code - (`jvmMain` is shared by android + desktop). `IrisController` routes frames - into `ChatStore`; `ChatScreen` renders. - -### How the hermes core signals a restart (verified in logs + code) - -- On **shutdown/restart** the core calls `adapter.send(home_channel, "⚠️ Gateway - restarting|shutting down — …")`. The android adapter classifies this into a - frame. The core **does** send this to the android home channel (log line: - `Sent shutdown notification to home channel android:android:default`). -- On **startup** the core sends "♻️ Gateway online — …" to home channels of - *configured* platforms, but **does NOT send it to the android platform** - (no such log line ever appears). So the app must generate the "online" notice - itself. - -### The gateway is a transient systemd user service - -- Unit: `iris-hermes-gw.service` (transient, `/run/user/1000/systemd/transient/`). -- **It does NOT auto-revive on SIGTERM.** After a SIGTERM it goes to `failed` - and stays down until you run: - `systemctl --user restart iris-hermes-gw.service` -- Logs: `~/.hermes/logs/gateway.log`. Outbox DB: `~/.hermes/android/outbox.db` - (table `outbox(cursor, chat_id, frame, created)`). - -## 3. What the previous attempt changed (all uncommitted, in working tree) - -Run `git diff` to see the full patch. Summary: - -### App (Kotlin) -- `app/shared/src/commonMain/kotlin/iris/util/TimeFormat.kt` + - `app/shared/src/jvmMain/kotlin/iris/util/TimeFormatJvm.kt` - — added `nowMillis(): Long` (expect/actual) for wall-clock timestamps. -- `app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt` - - `MessageItem` gained `isSystem: Boolean = false`. - - `addSystemMessage(lane, text)` — appends a centered local notice. - - `finalizeInterrupted()` — marks open `ToolItem`s `done=true, ok=false` and - streaming `MessageItem`s `streaming=false`, across all lanes. -- `app/shared/src/commonMain/kotlin/iris/state/IrisController.kt` - - In the `client.state.collect` block, tracks `prevState`: - - `Reconnecting → Connected`: posts `GATEWAY_ONLINE_MSG` - ("♻️ Gateway online — Hermes is back and ready.") to the current lane. - - `Connected → Reconnecting`: calls `chat.finalizeInterrupted()`. - - New constant `GATEWAY_ONLINE_MSG`. -- `app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt` - - Renders `isSystem` messages via a new centered `SystemMessage` composable - (non-selectable pill). - -### Gateway plugin (Python) -- `gateway-plugin/adapter.py` - - New `_is_gateway_lifecycle_notice(content)` — true when content contains - "Gateway restarting" / "Gateway shutting down" / "Gateway online". - - In `send()`, the tool-progress branch now skips lifecycle notices so they - fall through to **commentary** instead of a `tool.start` card. - -## 4. Current (BROKEN) state — what the user is seeing - -After several test restarts, the chat shows a **stack of repeated entries**: -multiple "⚠️ Gateway shutting down" cards (some with a **spinning** indicator, -some with a red ✗) interleaved with "♻️ Gateway online" pills. The **last** -entry is "Gateway shutting down" even though the app is **connected**. The user -is (correctly) unhappy: the ordering is wrong and a spinner is present. - -### The two concrete defects - -1. **Wrong ordering / stale "shutting down" last.** On reconnect the app posts - "♻️ Gateway online", but then the **`sync` catch-up replay** re-delivers the - "⚠️ Gateway shutting down" frame that the *old* gateway parked in the outbox - during shutdown. That replayed frame lands **after** the "online" notice, so - the newest line reads "shutting down" while connected. - -2. **Spinner returns.** The outbox still contains `tool.start` frames for the - "Gateway shutting down" notice that were written **before** the adapter fix - was loaded. The `sync` replay turns those into tool cards that never receive - a `tool.end`, so they spin. The adapter fix only stops *new* `tool.start` - frames; it does not clean up the already-parked ones. - -### Root cause (the part the previous attempt did NOT solve) - -The app's **reconnect catch-up (`sync`)** replays the outbox from the stored -cursor, and the app does **not** deduplicate or order these replayed lifecycle -frames against what it already rendered live. Consequences: -- Replayed "shutting down" frames appear after the locally-generated "online" - notice (wrong order). -- Replayed `tool.start` frames (pre-fix) become endless-spinning cards. -- Every restart adds another pair of entries (the "chaos" stack). - -The previous attempt treated the symptoms (finalize on disconnect, add an online -notice, reclassify new notices) but did **not** address the replay/dedup/ordering -problem, which is what actually produces the visible mess. - -## 5. What is verified vs. not - -- **Verified working:** Kotlin compiles (android + desktop), 53/53 Python - `tests/gateway/test_android.py` pass, the "♻️ Gateway online" pill renders, - and *new* lifecycle notices now render as commentary (not a tool card). -- **NOT working / still broken:** the chat accumulates a mis-ordered stack of - "shutting down"/"online" entries and a spinning tool card after restarts. - -## 6. Suggested direction for the next engineer - -Pick the cleanest of these (or a combination); the goal is: after a restart the -chat shows at most one "⚠️ Gateway restarting" then one "♻️ Gateway online", in -that order, with **no** spinner and **no** accumulation across restarts. - -1. **Dedup/order lifecycle notices in the app.** Give the restart/online notices - stable identities and reconcile them (like `MessageItem` is by `messageId`): - a replayed "shutting down" that is already present should not be re-added, - and the "online" notice should supersede the "shutting down" one (replace, - not append). Consider a single "gateway state" line that updates in place - (restarting → online) instead of appending a new line each transition. -2. **Stop the spinner at the source.** Ensure no `tool.start` is ever emitted - for a lifecycle notice (adapter fix is done for new frames) AND make the app - resilient: a `tool.start` with no matching `tool.end` should not spin - indefinitely (e.g., finalize on the next turn, on reconnect, or time out). -3. **Cursor hygiene.** Investigate why the `sync` replay re-delivers frames the - app already rendered live (live broadcasts may not advance the stored - `syncCursor`). If live frames should advance the cursor, the replay would not - re-add them. Check `GatewayClient.dial` (sends `sync`), the `TYPE_SYNC_DONE` - handler in `IrisController`, and the gateway `on_sync`/outbox cursor logic. -4. **Clean the existing mess.** The outbox (`~/.hermes/android/outbox.db`) and - the app's in-memory lanes currently hold the stacked test entries. Decide - whether to prune the outbox and/or reset the app's chat state - (`adb shell pm clear dev.iris.app`) so the user starts clean. - -## 7. Commands - -- Build/verify app: `cd app && ./gradlew :shared:compileKotlinDesktop :androidApp:compileDebugKotlin` -- Install on phone: `cd app && ./gradlew :androidApp:installDebug` (device `a5ca2a4b`) -- Python tests: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` -- Restart gateway: `systemctl --user restart iris-hermes-gw.service` -- Screenshot: `adb exec-out screencap -p > /tmp/shot.png` -- Launch app: `adb shell am start -n dev.iris.app/.MainActivity` -- Reset app state: `adb shell pm clear dev.iris.app` -- Gateway log: `~/.hermes/logs/gateway.log` - -## 8. Environment state at hand-off time - -- Gateway: **running** (systemd `iris-hermes-gw.service` active). -- App: **installed** on the phone with the (broken) changes above. -- Working tree: 6 modified files (see `git diff`), **nothing committed**. -- The phone's chat currently shows the stacked test entries described in §4. \ No newline at end of file diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 6f7ebaa..f0530e7 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -10,6 +10,10 @@ val composeVersion = "1.7.3" val coroutinesVersion = "1.9.0" val serializationVersion = "1.7.3" val okhttpVersion = "4.12.0" +// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x +// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore, +// and the -code module for language-aware syntax highlighting (Highlights). +val markdownVersion = "0.33.0" kotlin { androidTarget() @@ -27,11 +31,23 @@ kotlin { implementation("org.jetbrains.compose.foundation:foundation:$composeVersion") implementation("org.jetbrains.compose.material3:material3:$composeVersion") implementation("org.jetbrains.compose.ui:ui:$composeVersion") + // Full Material icon set (Icons.Filled.ContentCopy for the code + // copy buttons); -core only ships a small subset. + implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") // OkHttp is a JVM library; both targets here are JVM-based // (androidTarget + jvm("desktop")), so it resolves on both. implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") + // Markdown rendering for chat bubbles (M8): core parser/renderer, + // Material 3 themed defaults, and the -code module for syntax + // highlighting of fenced code blocks (Highlights). + implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion") + implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion") + implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion") + } + commonTest.dependencies { + implementation(kotlin("test")) } // M4: ExoPlayer (Media3) for inline audio/video playback (Android only). androidMain.dependencies { diff --git a/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt b/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt new file mode 100644 index 0000000..f60dec1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt @@ -0,0 +1,216 @@ +package iris.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.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 +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.compose.components.markdownComponents +import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock +import com.mikepenz.markdown.compose.elements.MarkdownCodeFence +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.markdownAnnotator +import com.mikepenz.markdown.model.rememberMarkdownState +import com.mikepenz.markdown.utils.getUnescapedTextInNode +import dev.snipme.highlights.Highlights +import dev.snipme.highlights.model.SyntaxThemes +import iris.ui.theme.IrisColors +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.intellij.markdown.MarkdownElementTypes + +/** + * Renders a chat message as markdown (M8): bold / italic / underscore, GFM + * tables, task-list checkboxes, and fenced code blocks with language-aware + * syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the + * raw markdown; [color] and [fontSize] match the surrounding bubble so the + * rendered text blends in. + */ +@Composable +fun MarkdownText( + text: String, + color: Color, + fontSize: TextUnit, + modifier: Modifier = Modifier, +) { + val state = rememberMarkdownState(text) + // 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 base = TextStyle(color = color, fontSize = fontSize) + val inlineCodeBackground = Color.White.copy(alpha = 0.08f) + // Inline-code span style (mirrors the library's codeSpanStyle): the + // monospace inlineCode typography plus the chip background. + val codeSpanStyle = + base.copy(fontFamily = FontFamily.Monospace, background = inlineCodeBackground).toSpanStyle() + // 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)) + .toSpanStyle() + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + // The text of the inline code most recently copied (null when idle). Read + // inside the annotator so a change re-renders the chip with the flash. + val copiedCode = remember { mutableStateOf(null) } + // The default CODE_SPAN handler pads inline code with a leading and a + // trailing space, which doubled the whitespace around `code` mid-line. + // 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) + } + pop() + true + } else { + false + } + }, + ) + Markdown( + markdownState = state, + modifier = modifier, + annotator = annotator, + 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( + 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), + h4 = base.copy(fontWeight = FontWeight.Bold), + h5 = base.copy(fontWeight = FontWeight.Bold), + h6 = base.copy(fontWeight = FontWeight.Bold), + text = base, + code = base.copy(fontSize = fontSize * 0.9f, fontFamily = FontFamily.Monospace), + inlineCode = base.copy(fontFamily = FontFamily.Monospace), + quote = base.copy(fontStyle = FontStyle.Italic), + paragraph = base, + ordered = base, + bullet = base, + list = base, + link = base.copy(textDecoration = TextDecoration.Underline), + table = base.copy(fontSize = fontSize * 0.95f), + ), + components = markdownComponents( + codeFence = { model -> + MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style -> + CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) + } + }, + codeBlock = { model -> + MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style -> + CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) + } + }, + // 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) + }, + ), + 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) + }, + ) +} + +/** + * 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. + */ +@Composable +private fun CodeBlockWithCopy( + code: String, + language: String?, + style: TextStyle, + highlights: Highlights.Builder, +) { + val clipboard = LocalClipboardManager.current + Box { + MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style) + 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), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = "Copy code", + tint = Color.White.copy(alpha = 0.6f), + ) + } + } + } +} \ 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 f958af4..ffd89e5 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -123,6 +123,7 @@ import iris.protocol.ROLE_USER import iris.protocol.SearchHit import iris.state.IrisController import iris.state.ToolDetail +import iris.ui.MarkdownText import iris.ui.rightClick import iris.ui.theme.IrisColors import iris.ui.theme.LocalUserTheme @@ -131,6 +132,8 @@ import iris.ui.theme.contrastText import iris.util.formatDayLabel import iris.util.formatTime import iris.util.localDayKey +import iris.util.prepareForMarkdown +import iris.util.preserveNewlinesAsHardBreaks import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -1910,11 +1913,14 @@ private fun MessageBubble( } if (isUser) { // M7: text + inline timestamp / delivery status at the end - // (reference look); the bubble hugs its content. + // (reference look); the bubble hugs its content. User text is + // rendered as markdown too (bold / italic / inline code), so a + // pasted snippet or emphasis shows up the same as in replies. Row(verticalAlignment = Alignment.Bottom) { if (msg.text.isNotBlank() || msg.streaming) { - Text( - msg.text + if (msg.streaming) " ▉" else "", + MarkdownText( + text = msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() + + if (msg.streaming) " ▉" else "", color = textColor, fontSize = 15.sp, ) @@ -1945,10 +1951,16 @@ private fun MessageBubble( } } else { if (msg.text.isNotBlank() || msg.streaming) { - Text( - msg.text + if (msg.streaming) " ▉" else "", + // 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; the ▉ cursor is kept while streaming. + val displayText = msg.text.prepareForMarkdown() + if (msg.streaming) " ▉" else "" + MarkdownText( + text = displayText, color = textColor, fontSize = if (isCommentary) 13.sp else 15.sp, + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt b/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt new file mode 100644 index 0000000..a6e1af1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/util/Markdown.kt @@ -0,0 +1,39 @@ +package iris.util + +/** + * Drops the leading newlines / blank lines a model reply often starts with, so + * the rendered text hugs the top of the bubble instead of leaving a blank gap. + * Leading indentation on the first real line is preserved, so an indented code + * block at the very start of a message still parses as code. + */ +fun String.prepareForMarkdown(): String { + val lines = split("\n") + var i = 0 + while (i < lines.size && lines[i].isBlank()) i++ + return lines.drop(i).joinToString("\n") +} + +/** + * Converts the single newlines a user typed into markdown hard breaks (two + * trailing spaces) so they render as line breaks instead of being collapsed + * into spaces by the parser (GFM treats a lone newline inside a paragraph as a + * space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the + * code content is not altered; blank lines are kept as-is since they already + * produce paragraph breaks. + */ +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 + } + inCodeBlock || line.isBlank() -> line + else -> line + " " + } + } + return out.joinToString("\n") +} \ No newline at end of file diff --git a/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt b/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt new file mode 100644 index 0000000..36eb4e9 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/util/MarkdownTest.kt @@ -0,0 +1,42 @@ +package iris.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MarkdownTest { + + @Test + fun stripsLeadingNewlines() { + assertEquals("Hello", "\n\nHello".prepareForMarkdown()) + assertEquals("Hello", "\nHello".prepareForMarkdown()) + assertEquals("Hello", "\r\n\r\nHello".prepareForMarkdown()) + } + + @Test + fun stripsLeadingBlankLines() { + assertEquals("Hello", "\n \n\t\nHello".prepareForMarkdown()) + assertEquals("a\nb", "\n\na\nb".prepareForMarkdown()) + } + + @Test + fun preservesLeadingIndentationOnFirstLine() { + // An indented code block at the very start must still parse as code. + assertEquals(" code", "\n\n code".prepareForMarkdown()) + } + + @Test + fun leavesInteriorWhitespaceUntouched() { + val md = "Line one\n\nLine two\n\n- item" + assertEquals(md, md.prepareForMarkdown()) + } + + @Test + fun noLeadingNewlinesIsUnchanged() { + assertEquals("Hello world", "Hello world".prepareForMarkdown()) + } + + @Test + fun allBlankBecomesEmpty() { + assertEquals("", "\n\n \n".prepareForMarkdown()) + } +} \ No newline at end of file