Skip to content

Commit d850b78

Browse files
authored
fix: handle markdown hard line breaks and list continuations (#483)
* fix: handle markdown hard line breaks and list continuation lines List items with indented continuation lines (no blank line separator) now merge into the preceding bullet instead of becoming orphan paragraphs. InlineMarkdown now converts two-trailing-space and backslash line breaks into <br> elements. Synced to the diff view's InlineMarkdown copy. Closes #482 For provenance purposes, this commit was AI assisted. * fix: allow bold/italic to span across hard line breaks Changed bold/italic regexes from .+? to [\s\S]+? so emphasis can match across newlines (per CommonMark spec). Moved hard break check after all ^-anchored inline patterns so bold/italic get first crack, then the recursive InlineMarkdown call inside <strong>/<em> handles the break. For provenance purposes, this commit was AI assisted.
1 parent 2636454 commit d850b78

File tree

9 files changed

+380
-6
lines changed

9 files changed

+380
-6
lines changed

packages/ui/components/Viewer.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -771,16 +771,16 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
771771
let key = 0;
772772

773773
while (remaining.length > 0) {
774-
// Bold: **text**
775-
let match = remaining.match(/^\*\*(.+?)\*\*/);
774+
// Bold: **text** ([\s\S]+? allows matching across hard line breaks)
775+
let match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
776776
if (match) {
777777
parts.push(<strong key={key++} className="font-semibold"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></strong>);
778778
remaining = remaining.slice(match[0].length);
779779
continue;
780780
}
781781

782782
// Italic: *text*
783-
match = remaining.match(/^\*(.+?)\*/);
783+
match = remaining.match(/^\*([\s\S]+?)\*/);
784784
if (match) {
785785
parts.push(<em key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
786786
remaining = remaining.slice(match[0].length);
@@ -908,6 +908,18 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
908908
continue;
909909
}
910910

911+
// Hard line break: two+ trailing spaces + newline, or backslash + newline
912+
match = remaining.match(/ {2,}\n|\\\n/);
913+
if (match && match.index !== undefined) {
914+
const before = remaining.slice(0, match.index);
915+
if (before) {
916+
parts.push(<InlineMarkdown key={key++} text={before} onOpenLinkedDoc={onOpenLinkedDoc} imageBaseDir={imageBaseDir} onImageClick={onImageClick} />);
917+
}
918+
parts.push(<br key={key++} />);
919+
remaining = remaining.slice(match.index + match[0].length);
920+
continue;
921+
}
922+
911923
// Find next special character or consume one regular character
912924
const nextSpecial = remaining.slice(1).search(/[\*`\[!]/);
913925
if (nextSpecial === -1) {

packages/ui/components/plan-diff/PlanCleanDiffView.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,8 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
589589
let key = 0;
590590

591591
while (remaining.length > 0) {
592-
let match = remaining.match(/^\*\*(.+?)\*\*/);
592+
// Bold: **text** ([\s\S]+? allows matching across hard line breaks)
593+
let match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
593594
if (match) {
594595
parts.push(
595596
<strong key={key++} className="font-semibold">
@@ -600,7 +601,8 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
600601
continue;
601602
}
602603

603-
match = remaining.match(/^\*(.+?)\*/);
604+
// Italic: *text*
605+
match = remaining.match(/^\*([\s\S]+?)\*/);
604606
if (match) {
605607
parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
606608
remaining = remaining.slice(match[0].length);
@@ -638,7 +640,19 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
638640
continue;
639641
}
640642

641-
const nextSpecial = remaining.slice(1).search(/[*`[]/);
643+
// Hard line break: two+ trailing spaces + newline, or backslash + newline
644+
match = remaining.match(/ {2,}\n|\\\n/);
645+
if (match && match.index !== undefined) {
646+
const before = remaining.slice(0, match.index);
647+
if (before) {
648+
parts.push(<InlineMarkdown key={key++} text={before} />);
649+
}
650+
parts.push(<br key={key++} />);
651+
remaining = remaining.slice(match.index + match[0].length);
652+
continue;
653+
}
654+
655+
const nextSpecial = remaining.slice(1).search(/[*`\[!]/);
642656
if (nextSpecial === -1) {
643657
parts.push(remaining);
644658
break;

packages/ui/utils/parser.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,69 @@ describe("parseMarkdownToBlocks — real-world plan regression", () => {
208208
expect(blocks[5].type).toBe("heading");
209209
});
210210
});
211+
212+
describe("parseMarkdownToBlocks — list continuation lines", () => {
213+
test("indented continuation line merges into preceding list item", () => {
214+
const md = "- First item with text\n that continues here\n- Second item";
215+
const blocks = parseMarkdownToBlocks(md);
216+
expect(blocks).toHaveLength(2);
217+
expect(blocks[0].type).toBe("list-item");
218+
expect(blocks[0].content).toBe("First item with text\nthat continues here");
219+
expect(blocks[1].type).toBe("list-item");
220+
expect(blocks[1].content).toBe("Second item");
221+
});
222+
223+
test("multiple continuation lines merge into one list item", () => {
224+
const md = "- Line one\n line two\n line three\n- Next";
225+
const blocks = parseMarkdownToBlocks(md);
226+
expect(blocks).toHaveLength(2);
227+
expect(blocks[0].type).toBe("list-item");
228+
expect(blocks[0].content).toBe("Line one\nline two\nline three");
229+
});
230+
231+
test("non-indented line after list item starts a new paragraph", () => {
232+
const md = "- Item\nNot indented";
233+
const blocks = parseMarkdownToBlocks(md);
234+
expect(blocks).toHaveLength(2);
235+
expect(blocks[0].type).toBe("list-item");
236+
expect(blocks[1].type).toBe("paragraph");
237+
expect(blocks[1].content).toBe("Not indented");
238+
});
239+
240+
test("blank line between list item and indented text prevents merging", () => {
241+
const md = "- Item\n\n Indented paragraph";
242+
const blocks = parseMarkdownToBlocks(md);
243+
expect(blocks).toHaveLength(2);
244+
expect(blocks[0].type).toBe("list-item");
245+
expect(blocks[1].type).toBe("paragraph");
246+
});
247+
248+
test("continuation does not swallow nested list items", () => {
249+
const md = "- Parent\n - Child\n- Sibling";
250+
const blocks = parseMarkdownToBlocks(md);
251+
expect(blocks).toHaveLength(3);
252+
expect(blocks[0].type).toBe("list-item");
253+
expect(blocks[0].content).toBe("Parent");
254+
expect(blocks[1].type).toBe("list-item");
255+
expect(blocks[1].content).toBe("Child");
256+
expect(blocks[2].type).toBe("list-item");
257+
expect(blocks[2].content).toBe("Sibling");
258+
});
259+
260+
test("continuation works when list item follows a blank line", () => {
261+
const md = "Some paragraph\n\n- Item with continuation\n that continues here";
262+
const blocks = parseMarkdownToBlocks(md);
263+
expect(blocks).toHaveLength(2);
264+
expect(blocks[0].type).toBe("paragraph");
265+
expect(blocks[1].type).toBe("list-item");
266+
expect(blocks[1].content).toBe("Item with continuation\nthat continues here");
267+
});
268+
269+
test("block-level elements after list items are not swallowed", () => {
270+
const md = "- Item\n# Heading";
271+
const blocks = parseMarkdownToBlocks(md);
272+
expect(blocks).toHaveLength(2);
273+
expect(blocks[0].type).toBe("list-item");
274+
expect(blocks[1].type).toBe("heading");
275+
});
276+
});

packages/ui/utils/parser.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
7878
let currentType: Block['type'] = 'paragraph';
7979
let currentLevel = 0;
8080
let bufferStartLine = 1; // Track the start line of the current buffer
81+
let lastLineWasBlank = false;
8182

8283
const flush = () => {
8384
if (buffer.length > 0) {
@@ -98,6 +99,8 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
9899
const line = lines[i];
99100
const trimmed = line.trim();
100101
const currentLineNum = i + 1; // 1-based index
102+
const prevLineWasBlank = lastLineWasBlank;
103+
lastLineWasBlank = false;
101104

102105
// Headings
103106
if (trimmed.startsWith('#')) {
@@ -232,6 +235,18 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
232235
if (trimmed === '') {
233236
flush();
234237
currentType = 'paragraph';
238+
lastLineWasBlank = true;
239+
continue;
240+
}
241+
// List continuation: indented line after a list item merges into it
242+
if (
243+
!prevLineWasBlank &&
244+
buffer.length === 0 &&
245+
blocks.length > 0 &&
246+
blocks[blocks.length - 1].type === 'list-item' &&
247+
/^\s+/.test(line)
248+
) {
249+
blocks[blocks.length - 1].content += '\n' + trimmed;
235250
continue;
236251
}
237252

test-fixtures/01-reported-issue.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
## Web Researcher Subagent
2+
3+
### Changes
4+
5+
**New file: `home/dot_config/opencode/exact_agents/web-researcher.md`**
6+
7+
Subagent definition with:
8+
- Model: kimi-k2p5-turbo (matches other subagents)
9+
- Mode: subagent (not hidden; available for direct user invocation too)
10+
- Permissions: blanket deny, then allow only `web *` and `jq *` (bash), plus Context7 MCP tools
11+
- Prompt covering:
12+
- Fallback priority: Context7 (curated library docs) → web search → web fetch
13+
- Error surfacing: MUST report all tool failures (auth errors, timeouts, HTTP errors, empty
14+
results) unconditionally and verbatim to the caller
15+
- Progressive refinement: if initial results are thin, refine query and search again
16+
- Constraint on max iterations to prevent runaway cycles
17+
- "When stuck" guidance
18+
- Output template (enforced structure for quality):
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# List Continuation Edge Cases
2+
3+
## Simple continuation
4+
5+
- This is a list item that has a continuation
6+
line that wraps to the next line
7+
- This is a normal single-line item
8+
9+
## Multiple continuation lines
10+
11+
- This item has multiple
12+
continuation lines that
13+
all should merge into one bullet
14+
- Next item
15+
16+
## Nested list with continuation
17+
18+
- Top level item
19+
- Nested item that has a long description
20+
that continues on the next line
21+
- Another nested item
22+
- Back to top level
23+
24+
## Deep nesting with continuations
25+
26+
- Level 0
27+
- Level 1 with continuation
28+
that wraps here
29+
- Level 2 item
30+
- Level 3 with continuation
31+
that also wraps
32+
33+
## Continuation after blank line (should NOT merge)
34+
35+
- First item
36+
37+
This should be a separate paragraph, not merged into the list item.
38+
39+
- Second item
40+
41+
## Non-indented line after list (should NOT merge)
42+
43+
- Item one
44+
This is not indented so it should be a new paragraph.
45+
- Item two
46+
47+
## Mixed content after list items
48+
49+
- Item followed by a heading
50+
# This Heading Should Not Merge
51+
52+
- Item followed by a blockquote
53+
> This quote should not merge
54+
55+
- Item followed by a code fence
56+
```ts
57+
const x = 1;
58+
```
59+
60+
- Item followed by a table
61+
| A | B |
62+
|---|---|
63+
| 1 | 2 |
64+
65+
- Item followed by a horizontal rule
66+
---
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Hard Line Break Tests
2+
3+
## Two trailing spaces (standard hard break)
4+
5+
This line has two trailing spaces
6+
and this should appear on a new line.
7+
8+
## Backslash hard break
9+
10+
This line ends with a backslash\
11+
and this should appear on a new line.
12+
13+
## Soft wrap (should NOT break)
14+
15+
This line has no trailing spaces
16+
and should flow as a single line with a space between.
17+
18+
## Multiple hard breaks in a row
19+
20+
Line one
21+
Line two
22+
Line three
23+
Line four
24+
25+
## Hard break inside a list item
26+
27+
- This list item has a hard break
28+
and continues on the next visual line
29+
- Normal item after
30+
31+
## Hard break with inline formatting
32+
33+
**Bold text with break
34+
continuation** and more text.
35+
36+
This has `inline code` then a break
37+
and continues here.
38+
39+
## Paragraph with no breaks (control case)
40+
41+
This is just a normal paragraph with no special line break handling.
42+
It should render as flowing text with spaces between lines.
43+
Nothing should change here at all.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Baseline — Nothing Here Should Change
2+
3+
## Headings
4+
5+
### Level 3 heading
6+
#### Level 4 heading
7+
8+
## Simple paragraph
9+
10+
This is a simple paragraph with no special formatting. It should render exactly as before — a single block of text.
11+
12+
## Paragraph with inline formatting
13+
14+
This has **bold**, *italic*, `inline code`, and [a link](https://example.com).
15+
16+
## Simple list
17+
18+
- Item one
19+
- Item two
20+
- Item three
21+
22+
## Nested list (no continuations)
23+
24+
- Parent item
25+
- Child item
26+
- Another child
27+
- Grandchild
28+
- Another parent
29+
30+
## Checkbox list
31+
32+
- [x] Completed task
33+
- [ ] Pending task
34+
- [x] Another done task
35+
36+
## Code block
37+
38+
```typescript
39+
function hello() {
40+
console.log("world");
41+
}
42+
```
43+
44+
## Blockquote
45+
46+
> This is a blockquote.
47+
> It has multiple lines.
48+
49+
## Table
50+
51+
| Header 1 | Header 2 | Header 3 |
52+
|----------|----------|----------|
53+
| Cell 1 | Cell 2 | Cell 3 |
54+
| Cell 4 | Cell 5 | Cell 6 |
55+
56+
## Horizontal rule
57+
58+
---
59+
60+
## Images and links
61+
62+
This paragraph has a [markdown link](https://example.com) and some `code`.
63+
64+
## Mixed content plan
65+
66+
### 1. Update the parser
67+
68+
- **Remove** the old regex
69+
- **Add** new pattern matching:
70+
```ts
71+
const pattern = /new-regex/;
72+
```
73+
- **Test** the changes
74+
75+
### 2. Update the renderer
76+
77+
- Modify `InlineMarkdown` component
78+
- Add `<br>` support
79+
- Run visual tests

0 commit comments

Comments
 (0)