Skip to content

Commit 798733d

Browse files
sirily11claude
andcommitted
feat(markdown): render user messages and table cells as markdown
Render user message bubbles through the markdown renderer (new rxCodeChatUser style tuned for the accent bubble), rewriting [ImageN] chips into rxcode-image:// links so taps still open the image preview, and preserving long-message collapse via height clipping. Also parse table cell contents as inline markdown so bold/code/links/italics render instead of showing literal syntax. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 8dfd710 commit 798733d

4 files changed

Lines changed: 75 additions & 53 deletions

File tree

Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,9 @@ private struct CompactChatMessageBubble: View {
9393
}
9494

9595
private func userTextBubble(_ text: String) -> some View {
96-
ChatTextContentView(
97-
text,
98-
size: ClaudeTheme.messageSize(14),
99-
color: ClaudeTheme.userBubbleText,
100-
lineSpacing: 2
101-
)
102-
.bubbleStyle(.user)
103-
.frame(maxWidth: 500, alignment: .trailing)
96+
MarkdownContentView(text: text, style: .rxCodeChatUser)
97+
.bubbleStyle(.user)
98+
.frame(maxWidth: 500, alignment: .trailing)
10499
}
105100

106101
private func assistantText(_ text: String) -> some View {

Packages/Sources/RxCodeChatKit/MarkdownView.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import RxCodeCore
33
import RxCodeMarkdown
44

55
extension MarkdownStyle {
6-
static var rxCodeChat: MarkdownStyle {
6+
public static var rxCodeChat: MarkdownStyle {
77
MarkdownStyle(
88
bodyFontSize: ClaudeTheme.messageSize(15),
99
bodyColor: ClaudeTheme.textPrimary,
@@ -19,6 +19,27 @@ extension MarkdownStyle {
1919
cornerRadius: ClaudeTheme.cornerRadiusSmall
2020
)
2121
}
22+
23+
/// Markdown styling tuned for the accent-tinted user bubble: text and
24+
/// inline accents derive from `userBubbleText` so they stay legible on the
25+
/// dark bubble background instead of using the global primary/accent colors.
26+
public static var rxCodeChatUser: MarkdownStyle {
27+
let text = ClaudeTheme.userBubbleText
28+
return MarkdownStyle(
29+
bodyFontSize: ClaudeTheme.messageSize(14),
30+
bodyColor: text,
31+
secondaryColor: text.opacity(0.85),
32+
accentColor: text,
33+
codeTextColor: text,
34+
codeBackground: text.opacity(0.12),
35+
codeHeaderBackground: text.opacity(0.08),
36+
borderColor: text.opacity(0.22),
37+
tableHeaderBackground: text.opacity(0.1),
38+
lineSpacing: 3,
39+
blockSpacing: 8,
40+
cornerRadius: ClaudeTheme.cornerRadiusSmall
41+
)
42+
}
2243
}
2344

2445
/// Renders markdown text through the pure SwiftUI markdown package while
@@ -28,6 +49,7 @@ public struct MarkdownContentView: View {
2849
let showsTrailingCursor: Bool
2950
let isCursorVisible: Bool
3051
let baseURL: URL?
52+
let style: MarkdownStyle
3153
let fadeNewText: Bool
3254
let onOpenLink: MarkdownView.LinkHandler?
3355

@@ -36,13 +58,15 @@ public struct MarkdownContentView: View {
3658
showsTrailingCursor: Bool = false,
3759
isCursorVisible: Bool = true,
3860
baseURL: URL? = nil,
61+
style: MarkdownStyle = .rxCodeChat,
3962
fadeNewText: Bool = false,
4063
onOpenLink: MarkdownView.LinkHandler? = nil
4164
) {
4265
self.text = text
4366
self.showsTrailingCursor = showsTrailingCursor
4467
self.isCursorVisible = isCursorVisible
4568
self.baseURL = baseURL
69+
self.style = style
4670
self.fadeNewText = fadeNewText
4771
self.onOpenLink = onOpenLink
4872
}
@@ -53,7 +77,7 @@ public struct MarkdownContentView: View {
5377
showsTrailingCursor: showsTrailingCursor,
5478
isCursorVisible: isCursorVisible,
5579
baseURL: baseURL,
56-
style: .rxCodeChat,
80+
style: style,
5781
fadeNewText: fadeNewText,
5882
onOpenLink: onOpenLink
5983
)

Packages/Sources/RxCodeChatKit/MessageBubble.swift

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ struct MessageBubble: View {
1919
/// Threshold (character count) for collapsing long text
2020
private static let longTextThreshold = 500
2121

22+
/// Height the collapsed (long) user bubble is clipped to before "Show more".
23+
private static let collapsedMaxHeight: CGFloat = 120
24+
2225
private enum AssistantRenderBlock: Identifiable {
2326
case text(MessageBlock)
2427
case tool(ToolCall)
@@ -234,24 +237,25 @@ struct MessageBubble: View {
234237
} else {
235238
VStack(alignment: .leading, spacing: 6) {
236239
let isLong = displayText.count > Self.longTextThreshold
237-
ChatTextContentView(
238-
attributed: chipifiedAttributedString(displayText),
239-
size: ClaudeTheme.messageSize(14),
240-
color: ClaudeTheme.userBubbleText,
241-
maximumNumberOfLines: isLong && !isLongTextExpanded ? 5 : nil
240+
let collapsed = isLong && !isLongTextExpanded
241+
MarkdownContentView(
242+
text: markdownUserText(displayText),
243+
style: .rxCodeChatUser
242244
) { url in
243245
// Intercept the synthetic `rxcode-image://<index>` link emitted
244-
// by chipifiedAttributedString and open the matching image in the
245-
// preview sheet rather than the system browser.
246+
// by markdownUserText and open the matching image in the preview
247+
// sheet rather than the system browser.
246248
guard url.scheme == "rxcode-image",
247249
let index = Int(url.host ?? ""),
248250
let path = imagePath(forChipIndex: index) else {
249-
return false
251+
return .systemAction
250252
}
251253
previewImagePath = path
252-
return true
254+
return .handled
253255
}
254-
.fixedSize(horizontal: false, vertical: true)
256+
.frame(maxHeight: collapsed ? Self.collapsedMaxHeight : nil, alignment: .topLeading)
257+
.clipped()
258+
.fixedSize(horizontal: false, vertical: !collapsed)
255259
if isLong {
256260
Button {
257261
withAnimation(.easeInOut(duration: 0.2)) {
@@ -660,32 +664,21 @@ struct MessageBubble: View {
660664
return result
661665
}
662666

663-
/// Renders `[Image\d+]` tokens with accent-tinted chip styling. The same tokens
664-
/// are inserted into the input bar by `WindowState.insertImageToken` and drawn
665-
/// with a rounded background there via `ChipLayoutManager`; this mirrors that
666-
/// treatment in the sent user bubble.
667-
///
668-
/// Each chip also gets a `rxcode-image://<index>` link attribute; an
669-
/// `environment(\.openURL, ...)` handler on the Text intercepts the tap and
670-
/// opens the corresponding image in `MessageImagePreviewSheet`. The index
671-
/// matches `WindowState.imageIndex(for:)` — 1-based, image-only.
672-
private func chipifiedAttributedString(_ text: String) -> AttributedString {
673-
var attr = AttributedString(text)
667+
/// Prepares user-message text for the markdown renderer by rewriting `[ImageN]`
668+
/// chip tokens into `[ImageN](rxcode-image://N)` links. The renderer then draws
669+
/// them as tappable accents; the `rxcode-image` scheme is intercepted by the
670+
/// bubble's link handler to open the image preview rather than the browser. The
671+
/// same `[ImageN]` tokens are inserted into the input bar by
672+
/// `WindowState.insertImageToken`; the index matches `WindowState.imageIndex(for:)`
673+
/// — 1-based, image-only.
674+
private func markdownUserText(_ text: String) -> String {
674675
let ns = text as NSString
675676
let fullRange = NSRange(location: 0, length: ns.length)
676-
Self.imageChipRegex.enumerateMatches(in: text, range: fullRange) { match, _, _ in
677-
guard let m = match,
678-
let range = Range(m.range, in: attr),
679-
m.numberOfRanges >= 2,
680-
let indexRange = Range(m.range(at: 1), in: text),
681-
let index = Int(text[indexRange]) else { return }
682-
attr[range].backgroundColor = ClaudeTheme.accent.opacity(0.22)
683-
attr[range].foregroundColor = ClaudeTheme.accent
684-
attr[range].font = .system(size: ClaudeTheme.messageSize(13), weight: .medium)
685-
attr[range].link = URL(string: "rxcode-image://\(index)")
686-
attr[range].underlineStyle = nil
687-
}
688-
return attr
677+
return Self.imageChipRegex.stringByReplacingMatches(
678+
in: text,
679+
range: fullRange,
680+
withTemplate: "[Image$1](rxcode-image://$1)"
681+
)
689682
}
690683

691684
/// Resolve `[ImageN]` chip index (1-based) to a concrete image file path.

Packages/Sources/RxCodeMarkdown/MarkdownView.swift

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ private struct MarkdownDocumentView: View {
275275
)
276276
.opacity(opacity(for: range))
277277
case .table(let headers, let rows, let range):
278-
MarkdownTableView(headers: headers, rows: rows, style: style)
278+
MarkdownTableView(headers: headers, rows: rows, baseURL: baseURL, style: style)
279279
.opacity(opacity(for: range))
280280
case .divider(let range):
281281
Rectangle()
@@ -605,25 +605,22 @@ private struct MarkdownCodeBlockView: View {
605605
private struct MarkdownTableView: View {
606606
let headers: [String]
607607
let rows: [[String]]
608+
let baseURL: URL?
608609
let style: MarkdownStyle
609610

610611
var body: some View {
611612
ScrollView(.horizontal, showsIndicators: false) {
612613
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
613614
GridRow {
614615
ForEach(Array(headers.enumerated()), id: \.offset) { _, header in
615-
Text(header)
616-
.font(.system(size: style.bodyFontSize * 0.92, weight: .semibold))
617-
.foregroundStyle(style.bodyColor)
616+
cell(header, weight: .semibold, color: style.bodyColor)
618617
.padding(.vertical, 5)
619618
}
620619
}
621620
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
622621
GridRow {
623622
ForEach(0..<max(headers.count, row.count), id: \.self) { index in
624-
Text(index < row.count ? row[index] : "")
625-
.font(.system(size: style.bodyFontSize * 0.92))
626-
.foregroundStyle(style.secondaryColor)
623+
cell(index < row.count ? row[index] : "", weight: .regular, color: style.secondaryColor)
627624
.padding(.vertical, 3)
628625
}
629626
}
@@ -637,6 +634,19 @@ private struct MarkdownTableView: View {
637634
)
638635
}
639636
}
637+
638+
@ViewBuilder
639+
private func cell(_ content: String, weight: Font.Weight, color: Color) -> some View {
640+
InlineMarkdownText(
641+
inlines: MarkdownDocumentParser.parseInlines(content),
642+
style: style,
643+
baseURL: baseURL,
644+
fontSize: style.bodyFontSize * 0.92,
645+
weight: weight,
646+
overrideColor: color,
647+
fadeSegments: []
648+
)
649+
}
640650
}
641651

642652
private struct CachedMarkdownImage: View {
@@ -763,8 +773,8 @@ private func copyToPasteboard(_ text: String) {
763773
764774
| Component | Status |
765775
| --- | --- |
766-
| Parser | Native |
767-
| Images | Cached |
776+
| **Parser** | `Native` |
777+
| [Images](https://rxlab.dev) | *Cached* |
768778
769779
```swift
770780
MarkdownView(text: markdown, fadeNewText: true)

0 commit comments

Comments
 (0)