fix(chat): preserve ordered list numbering in loose lists (#959) (#963)

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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-24 00:16:27 +03:00
1 parent 764cb3455f
commit 5fd732be9b
2 files changed
+66 -6

No files matched your search

@@ -63,7 +63,7 @@ private val TABLE_COL_WIDTH = 160.dp
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
private val BULLET_RE = Regex("""^\s*[-*+]\s+(.*)""") private val BULLET_RE = Regex("""^\s*[-*+]\s+(.*)""")
private val TASK_RE = Regex("""^\s*[-*+]\s+\[([ xX])\]\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. * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming.
@@ -754,12 +754,25 @@ internal fun parseBlocks(src: String): List<MdBlock> {
} }
ORDERED_RE.matches(line) -> { ORDERED_RE.matches(line) -> {
val items = mutableListOf<String>() // Capture the source number so custom/loose lists keep their order.
while (i < lines.size && ORDERED_RE.matches(lines[i])) { // A blank line only breaks the list when the next non-blank line is NOT an
items.add(ORDERED_RE.find(lines[i])!!.groupValues[1]) // ordered item — loose lists (blank lines between items) stay one list.
i++ val items = mutableListOf<Pair<Int, String>>()
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. // Standalone Markdown image: ![alt](uri) on its own line.
@@ -288,4 +288,51 @@ class MarkdownTextFeatureTest {
assertTrue(videoAttachment.isVideo) assertTrue(videoAttachment.isVideo)
assertFalse(videoAttachment.isImage) 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<MdBlock.Ordered>()
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<MdBlock.Ordered>()
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<MdBlock.Ordered>()
assertEquals(listOf(1, 2, 3), blocks.map { it.index })
}
} }