From 5fd732be9b1aec3281b7f46b15976199a0e2df60 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:16:27 +0300 Subject: [PATCH] fix(chat): preserve ordered list numbering in loose lists (#959) (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORDERED_RE only captured item text and discarded the source number, and the parse loop broke on the first blank line — so loose numbered lists (blank lines between items) re-indexed every item to 1. - capture the prefix number and emit it as MdBlock.Ordered.index - treat a blank line as list-internal when the next non-blank line is another ordered item, so loose lists stay one ordered list - keep sequential numbering for tight lists (1,2,3) and honor custom starts add regression tests in MarkdownTextFeatureTest (loose, custom-start, tight). Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 25 +++++++--- .../ui/chat/MarkdownTextFeatureTest.kt | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index c2bdd24e..8f5de1c8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -63,7 +63,7 @@ private val TABLE_COL_WIDTH = 160.dp private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") private val BULLET_RE = Regex("""^\s*[-*+]\s+(.*)""") private val TASK_RE = Regex("""^\s*[-*+]\s+\[([ xX])\]\s+(.*)""") -private val ORDERED_RE = Regex("""^\s*\d+\.\s+(.*)""") +private val ORDERED_RE = Regex("""^\s*(\d+)\.\s+(.*)""") /** * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming. @@ -754,12 +754,25 @@ internal fun parseBlocks(src: String): List { } ORDERED_RE.matches(line) -> { - val items = mutableListOf() - while (i < lines.size && ORDERED_RE.matches(lines[i])) { - items.add(ORDERED_RE.find(lines[i])!!.groupValues[1]) - i++ + // Capture the source number so custom/loose lists keep their order. + // A blank line only breaks the list when the next non-blank line is NOT an + // ordered item — loose lists (blank lines between items) stay one list. + val items = mutableListOf>() + var j = i + while (j < lines.size) { + val m = ORDERED_RE.find(lines[j]) + if (m != null) { + val n = m.groupValues[1].toIntOrNull() ?: (items.size + 1) + items.add(n to m.groupValues[2]) + j++ + } else if (lines[j].isBlank() && j + 1 < lines.size && ORDERED_RE.matches(lines[j + 1])) { + j++ + } else { + break + } } - items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) } + items.forEach { (n, t) -> blocks.add(MdBlock.Ordered(n, t)) } + i = j } // Standalone Markdown image: ![alt](uri) on its own line. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 08745f49..636e7366 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -288,4 +288,51 @@ class MarkdownTextFeatureTest { assertTrue(videoAttachment.isVideo) assertFalse(videoAttachment.isImage) } + + // 16. ORDERED LIST — loose lists must preserve numbering (issue #959) + @Test + fun testOrderedList_looseListPreservesNumbers() { + val md = + """ + 1. First + + 2. Second + + 3. Third + """.trimIndent() + val blocks = parseBlocks(md).filterIsInstance() + assertEquals(3, blocks.size) + assertEquals(1, blocks[0].index) + assertEquals(2, blocks[1].index) + assertEquals(3, blocks[2].index) + assertEquals("First", blocks[0].text) + assertEquals("Third", blocks[2].text) + } + + // 16b. ORDERED LIST — custom starting number is preserved (issue #959) + @Test + fun testOrderedList_customStartNumberPreserved() { + val md = + """ + 2. Two + 3. Three + """.trimIndent() + val blocks = parseBlocks(md).filterIsInstance() + assertEquals(2, blocks.size) + assertEquals(2, blocks[0].index) + assertEquals(3, blocks[1].index) + } + + // 16c. ORDERED LIST — tight list still numbers sequentially + @Test + fun testOrderedList_tightList() { + val md = + """ + 1. one + 2. two + 3. three + """.trimIndent() + val blocks = parseBlocks(md).filterIsInstance() + assertEquals(listOf(1, 2, 3), blocks.map { it.index }) + } }