Skip to content

Commit 9253175

Browse files
committed
fix(ios): preserve assistant line breaks in chat
1 parent 85ee712 commit 9253175

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,58 @@ enum ChatMarkdownPreprocessor {
220220
output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n")
221221
return output.trimmingCharacters(in: .whitespacesAndNewlines)
222222
}
223+
224+
static func markdownForRendering(_ raw: String) -> String {
225+
let lines = raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
226+
var fenceMarker: Character?
227+
var fenceLength = 0
228+
var output: [String] = []
229+
230+
for index in lines.indices {
231+
let line = lines[index]
232+
let trimmed = line.trimmingCharacters(in: .whitespaces)
233+
let next = index + 1 < lines.count ? lines[index + 1] : nil
234+
let nextTrimmed = next?.trimmingCharacters(in: .whitespaces) ?? ""
235+
if let fence = self.fenceBoundary(in: line) {
236+
if let currentMarker = fenceMarker {
237+
if fence.marker == currentMarker, fence.count >= fenceLength, !fence.hasTrailingContent {
238+
fenceMarker = nil
239+
fenceLength = 0
240+
}
241+
} else {
242+
fenceMarker = fence.marker
243+
fenceLength = fence.count
244+
}
245+
output.append(line)
246+
continue
247+
}
248+
if fenceMarker != nil ||
249+
line.hasPrefix(" ") ||
250+
line.hasPrefix("\t") ||
251+
trimmed.isEmpty ||
252+
next == nil ||
253+
nextTrimmed.isEmpty ||
254+
self.fenceBoundary(in: next ?? "") != nil ||
255+
line.hasSuffix(" ") ||
256+
line.hasSuffix("\\")
257+
{
258+
output.append(line)
259+
continue
260+
}
261+
output.append("\(line) ")
262+
}
263+
264+
return output.joined(separator: "\n")
265+
}
266+
267+
private static func fenceBoundary(in line: String) -> (marker: Character, count: Int, hasTrailingContent: Bool)? {
268+
let leadingSpaces = line.prefix { $0 == " " }.count
269+
guard leadingSpaces < 4, !line.hasPrefix("\t") else { return nil }
270+
let trimmed = line.trimmingCharacters(in: .whitespaces)
271+
guard let marker = trimmed.first, marker == "`" || marker == "~" else { return nil }
272+
let markerCount = trimmed.prefix { $0 == marker }.count
273+
guard markerCount >= 3 else { return nil }
274+
let remaining = trimmed.dropFirst(markerCount).trimmingCharacters(in: .whitespaces)
275+
return (marker: marker, count: markerCount, hasTrailingContent: !remaining.isEmpty)
276+
}
223277
}

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ struct ChatMarkdownRenderer: View {
4040
}
4141

4242
private func markdownText(_ markdown: String) -> AttributedString {
43+
let renderMarkdown = ChatMarkdownPreprocessor.markdownForRendering(markdown)
4344
let options = AttributedString.MarkdownParsingOptions(
4445
interpretedSyntax: .full,
4546
failurePolicy: .returnPartiallyParsedIfPossible)
46-
return (try? AttributedString(markdown: markdown, options: options)) ?? AttributedString(markdown)
47+
return (try? AttributedString(markdown: renderMarkdown, options: options)) ?? AttributedString(markdown)
4748
}
4849
}
4950

apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,91 @@ struct ChatMarkdownPreprocessorTests {
150150
#expect(result.cleaned == "Hello there\nActual message")
151151
}
152152

153+
@Test func keepsCleanedTextPlainWhenInputHasSingleLineBreaks() {
154+
let markdown = """
155+
First line
156+
Second line
157+
158+
Third paragraph
159+
"""
160+
161+
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
162+
163+
#expect(result.cleaned == "First line\nSecond line\n\nThird paragraph")
164+
}
165+
166+
@Test func addsMarkdownHardBreaksForVisibleSingleLineRendering() {
167+
let markdown = """
168+
First line
169+
Second line
170+
171+
Third paragraph
172+
"""
173+
174+
let result = ChatMarkdownPreprocessor.markdownForRendering(markdown)
175+
176+
#expect(result == "First line \nSecond line\n\nThird paragraph")
177+
}
178+
179+
@Test func leavesFencedCodeLineBreaksUntouchedForRendering() {
180+
let markdown = """
181+
Before
182+
```text
183+
alpha
184+
beta
185+
```
186+
After
187+
"""
188+
189+
let result = ChatMarkdownPreprocessor.markdownForRendering(markdown)
190+
191+
#expect(result == "Before\n```text\nalpha\nbeta\n```\nAfter")
192+
}
193+
194+
@Test func leavesTildeFencedCodeLineBreaksUntouchedForRendering() {
195+
let markdown = """
196+
Before
197+
~~~text
198+
alpha
199+
beta
200+
~~~
201+
After
202+
"""
203+
204+
let result = ChatMarkdownPreprocessor.markdownForRendering(markdown)
205+
206+
#expect(result == "Before\n~~~text\nalpha\nbeta\n~~~\nAfter")
207+
}
208+
209+
@Test func leavesIndentedCodeLineBreaksUntouchedForRendering() {
210+
let markdown = """
211+
Before
212+
alpha
213+
beta
214+
After
215+
"""
216+
217+
let result = ChatMarkdownPreprocessor.markdownForRendering(markdown)
218+
219+
#expect(result == "Before \n alpha\n beta\nAfter")
220+
}
221+
222+
@Test func leavesShorterFenceTextInsideLongFenceUntouchedForRendering() {
223+
let markdown = """
224+
Before
225+
````markdown
226+
```text
227+
alpha
228+
```
229+
````
230+
After
231+
"""
232+
233+
let result = ChatMarkdownPreprocessor.markdownForRendering(markdown)
234+
235+
#expect(result == "Before\n````markdown\n```text\nalpha\n```\n````\nAfter")
236+
}
237+
153238
@Test func stripsTrailingUntrustedContextSuffix() {
154239
let markdown = """
155240
User-visible text

0 commit comments

Comments
 (0)