diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift index 0edbde2c6590..d197f87fef8a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift @@ -21,8 +21,9 @@ struct ChatMarkdownRenderer: View { var body: some View { let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text) + let renderMarkdown = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: processed.cleaned) VStack(alignment: .leading, spacing: 10) { - Text(self.markdownText(processed.cleaned)) + Text(self.markdownText(renderMarkdown)) .font(self.font) .foregroundStyle(self.textColor) .tint(self.linkColor) @@ -47,6 +48,174 @@ struct ChatMarkdownRenderer: View { } } +enum ChatMarkdownDisplayPreprocessor { + static func preserveChatSoftBreaks(in markdown: String) -> String { + let normalized = markdown.replacingOccurrences(of: "\r\n", with: "\n") + let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard lines.count > 1 else { return normalized } + + var output = "" + var fence: Fence? + let tableRows = self.tableRowIndices(in: lines) + + for index in lines.indices { + let line = lines[index] + let wasInFence = fence != nil + let fenceBoundary = self.fenceBoundary(in: line, activeFence: fence) + if case let .open(nextFence) = fenceBoundary { + fence = nextFence + } else if case .close = fenceBoundary { + fence = nil + } + + output += line + + guard index < lines.index(before: lines.endIndex) else { + continue + } + + let nextLine = lines[lines.index(after: index)] + let nextIndex = lines.index(after: index) + if self.shouldPreserveSoftBreak( + after: line, + before: nextLine, + inTable: tableRows.contains(index) || tableRows.contains(nextIndex), + inFence: wasInFence, + fenceBoundary: fenceBoundary) + { + output += " \n" + } else { + output += "\n" + } + } + + return output + } + + private enum FenceBoundary { + case none + case open(Fence) + case close + } + + private struct Fence { + let character: Character + let count: Int + let hasOnlyTrailingWhitespace: Bool + } + + private static func shouldPreserveSoftBreak( + after line: String, + before nextLine: String, + inTable: Bool, + inFence: Bool, + fenceBoundary: FenceBoundary) -> Bool + { + guard !inTable else { return false } + guard !inFence else { return false } + guard case .none = fenceBoundary else { return false } + + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let nextTrimmed = nextLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !nextTrimmed.isEmpty else { return false } + guard !self.hasMarkdownHardBreak(line) else { return false } + guard !self.isBlockMarkdownLine(line), !self.isBlockMarkdownLine(nextLine) else { return false } + return true + } + + private static func hasMarkdownHardBreak(_ line: String) -> Bool { + line.hasSuffix("\\") || line.hasSuffix(" ") + } + + private static func isBlockMarkdownLine(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + return self.matches(line, #"^\s{0,3}#{1,6}(\s|$)"#) + || self.matches(line, #"^\s{0,3}>"#) + || self.matches(line, #"^\s{0,3}([-+*])\s+"#) + || self.matches(line, #"^\s{0,3}\d{1,9}[.)]\s+"#) + || self.matches(line, #"^( {4}|\t)"#) + || self.matches(line, #"^\s{0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,}|={3,})$"#) + } + + private static func tableRowIndices(in lines: [String]) -> Set { + var indices = Set() + for index in lines.indices where index > lines.startIndex { + guard self.isTableDelimiterLine(lines[index]), lines[lines.index(before: index)].contains("|") else { + continue + } + + indices.insert(lines.index(before: index)) + indices.insert(index) + + var cursor = lines.index(after: index) + while cursor < lines.endIndex, lines[cursor].contains("|") { + indices.insert(cursor) + cursor = lines.index(after: cursor) + } + } + return indices + } + + private static func isTableDelimiterLine(_ line: String) -> Bool { + self.matches(line, #"^\s{0,3}\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$"#) + } + + private static func fenceBoundary(in line: String, activeFence: Fence?) -> FenceBoundary { + guard let candidate = self.fenceCandidate(in: line) else { + return .none + } + + guard let activeFence else { + return .open(candidate) + } + + if candidate.character == activeFence.character, + candidate.count >= activeFence.count, + candidate.hasOnlyTrailingWhitespace + { + return .close + } + return .none + } + + private static func fenceCandidate(in line: String) -> Fence? { + var cursor = line.startIndex + var spaces = 0 + while cursor < line.endIndex, line[cursor] == " ", spaces < 4 { + spaces += 1 + cursor = line.index(after: cursor) + } + guard spaces <= 3, cursor < line.endIndex else { + return nil + } + + let character = line[cursor] + guard character == "`" || character == "~" else { + return nil + } + + var count = 0 + while cursor < line.endIndex, line[cursor] == character { + count += 1 + cursor = line.index(after: cursor) + } + guard count >= 3 else { + return nil + } + let trailing = line[cursor...] + return Fence( + character: character, + count: count, + hasOnlyTrailingWhitespace: trailing.allSatisfy(\.isWhitespace)) + } + + private static func matches(_ line: String, _ pattern: String) -> Bool { + line.range(of: pattern, options: .regularExpression) != nil + } +} + @MainActor private struct InlineImageList: View { let images: [ChatMarkdownPreprocessor.InlineImage] diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownDisplayPreprocessorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownDisplayPreprocessorTests.swift new file mode 100644 index 000000000000..bb90929b9e85 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownDisplayPreprocessorTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import OpenClawChatUI + +struct ChatMarkdownDisplayPreprocessorTests { + @Test func `converts plain chat soft breaks to markdown hard breaks`() throws { + let markdown = """ + alpha + beta + gamma + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect( + prepared == """ + alpha + beta + gamma + """) + #expect(try self.renderedCharacters(prepared) == "alpha\nbeta\ngamma") + } + + @Test func `keeps blank line paragraph boundaries`() { + let markdown = """ + alpha + + beta + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect(prepared == markdown) + } + + @Test func `does not duplicate existing hard breaks`() { + let markdown = """ + alpha + beta\\ + gamma + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect(prepared == markdown) + } + + @Test func `preserves fenced code blocks`() { + let markdown = """ + ```swift + alpha + beta + ``` + after + next + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect( + prepared == """ + ```swift + alpha + beta + ``` + after + next + """) + } + + @Test func `keeps fence like code content inside active fence`() { + let markdown = """ + ```text + ``` not a close + still code + ``` + after + next + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect( + prepared == """ + ```text + ``` not a close + still code + ``` + after + next + """) + } + + @Test func `preserves block markdown structure`() { + let markdown = """ + Intro + - item one + - item two + + # Heading + > quote + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect(prepared == markdown) + } + + @Test func `preserves table like markdown rows`() { + let markdown = """ + A | B + --- | --- + 1 | 2 + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect(prepared == markdown) + } + + @Test func `converts plain pipe prose soft breaks`() { + let markdown = """ + Use foo | bar + then continue + """ + + let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown) + + #expect( + prepared == """ + Use foo | bar + then continue + """) + } + + private func renderedCharacters(_ markdown: String) throws -> String { + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .full, + failurePolicy: .returnPartiallyParsedIfPossible) + let attributed = try AttributedString(markdown: markdown, options: options) + return String(attributed.characters) + } +}