Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Packages/Sources/RxCodeChatKit/BubbleStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ struct BubbleStyle: ViewModifier {

private var padding: EdgeInsets {
switch variant {
case .user:
return EdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20)
case .tool, .toolError:
return Self.toolPadding
default:
Expand Down
11 changes: 9 additions & 2 deletions Packages/Sources/RxCodeChatKit/ChatBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ public final class ChatBridge {
/// updates as the CLI emits cumulative usage. Surfaced beside the streaming indicator.
public var liveOutputTokens: Int = 0
public var lastTurnContextUsedPercentage: Double?
public var agentProvider: AgentProvider = .claudeCode
public var modelDisplayName: String = ""
public var sessionStats: ChatSessionStats = ChatSessionStats()
public var autoPreviewSettings: AttachmentAutoPreviewSettings = AttachmentAutoPreviewSettings()
public var appVersion: String = ""
public var claudeVersion: String?
public var codexVersion: String?

// MARK: - Action Handlers (set up by the app target)

Expand All @@ -36,7 +38,8 @@ public final class ChatBridge {
public var sendSlashCommandHandler: ((String) async -> Void)?
public var runTerminalCommandHandler: ((String) async -> Void)?
public var editAndResendHandler: ((UUID, String) async -> Void)?
public var fetchRateLimitHandler: (() async -> RateLimitUsage?)?
public var fetchRateLimitHandler: ((AgentProvider) async -> RateLimitUsage?)?
public var setSessionProviderHandler: ((AgentProvider) -> Void)?
public var togglePlanModeHandler: (() async -> Void)?
public var enqueueMessageHandler: ((String, [Attachment]) -> Void)?
public var removeQueuedMessageHandler: ((UUID) -> Void)?
Expand Down Expand Up @@ -71,7 +74,11 @@ public final class ChatBridge {
}

public func fetchRateLimit() async -> RateLimitUsage? {
await fetchRateLimitHandler?()
await fetchRateLimitHandler?(agentProvider)
}

public func setSessionProvider(_ provider: AgentProvider) {
setSessionProviderHandler?(provider)
}

public func togglePlanMode() async {
Expand Down
206 changes: 198 additions & 8 deletions Packages/Sources/RxCodeChatKit/MarkdownView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import AppKit
import RxCodeCore

// MARK: - Render Group Cache
Expand Down Expand Up @@ -35,11 +36,15 @@ private final class RenderGroupCache: @unchecked Sendable {
/// Renders markdown text with styled code blocks, headers, lists, and rich text.
struct MarkdownContentView: View {
let text: String
let showsTrailingCursor: Bool
let isCursorVisible: Bool
@State private var cachedGroups: [RenderGroup]
@State private var cachedText: String

init(text: String) {
init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) {
self.text = text
self.showsTrailingCursor = showsTrailingCursor
self.isCursorVisible = isCursorVisible
let groups: [RenderGroup]
if let cached = RenderGroupCache.shared.get(text) {
groups = cached
Expand All @@ -53,12 +58,14 @@ struct MarkdownContentView: View {

var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(cachedGroups.enumerated()), id: \.offset) { _, group in
ForEach(Array(cachedGroups.enumerated()), id: \.offset) { index, group in
switch group {
case .attributedText(let attrStr):
Text(attrStr)
.textSelection(.enabled)
.lineSpacing(6)
MarkdownAttributedTextView(
content: attrStr,
showsTrailingCursor: showsTrailingCursor && index == cachedGroups.count - 1,
isCursorVisible: isCursorVisible
)
.frame(maxWidth: .infinity, alignment: .leading)
case .blockquote(let attrStr):
BlockquoteView(content: attrStr)
Expand Down Expand Up @@ -533,6 +540,191 @@ private func parseInlineMarkdown(_ content: String) -> AttributedString {
return result
}

// MARK: - Markdown Attributed Text

nonisolated(unsafe) private let inlineCodeBackgroundAttribute = NSAttributedString.Key("rxcode.inlineCodeBackground")

private struct MarkdownAttributedTextView: NSViewRepresentable {
let content: AttributedString
var showsTrailingCursor: Bool = false
var isCursorVisible: Bool = true

func makeNSView(context: Context) -> InlineCodeTextView {
let textView = InlineCodeTextView()
textView.isEditable = false
textView.isSelectable = true
textView.drawsBackground = false
textView.backgroundColor = .clear
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.heightTracksTextView = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
return textView
}

func updateNSView(_ textView: InlineCodeTextView, context: Context) {
let attributed = Self.nsAttributedString(
from: content,
showsTrailingCursor: showsTrailingCursor,
isCursorVisible: isCursorVisible
)
textView.linkTextAttributes = [
.foregroundColor: NSColor(ClaudeTheme.accent),
.underlineStyle: NSUnderlineStyle.single.rawValue
]
textView.textStorage?.setAttributedString(attributed)
textView.invalidateIntrinsicContentSize()
}

func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: InlineCodeTextView, context: Context) -> CGSize? {
let width = proposal.width ?? 500
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer else {
return CGSize(width: width, height: 0)
}
textContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
return CGSize(width: width, height: ceil(usedRect.height))
}

private static func nsAttributedString(
from content: AttributedString,
showsTrailingCursor: Bool,
isCursorVisible: Bool
) -> NSAttributedString {
let result = NSMutableAttributedString(content)
let fullRange = NSRange(location: 0, length: result.length)
guard fullRange.length > 0 else {
if showsTrailingCursor {
appendTrailingCursor(to: result, isVisible: isCursorVisible)
}
return result
}

let defaultTextColor = NSColor(ClaudeTheme.textPrimary)
let defaultFont = NSFont.systemFont(ofSize: ClaudeTheme.messageSize(15))
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = 6
result.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange)
result.enumerateAttribute(.font, in: fullRange) { value, range, _ in
guard value == nil else { return }
result.addAttribute(.font, value: defaultFont, range: range)
}
result.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in
guard value == nil else { return }
result.addAttribute(.foregroundColor, value: defaultTextColor, range: range)
}

var location = 0
for run in content.runs {
let runText = String(content[run.range].characters)
let length = (runText as NSString).length
defer { location += length }
guard length > 0,
let intent = run.inlinePresentationIntent,
intent.contains(.code) else { continue }

let range = NSRange(location: location, length: length)
result.addAttributes([
.font: NSFont.monospacedSystemFont(ofSize: ClaudeTheme.messageSize(14), weight: .regular),
.foregroundColor: defaultTextColor,
inlineCodeBackgroundAttribute: true
], range: range)
result.removeAttribute(.backgroundColor, range: range)
}
if showsTrailingCursor {
appendTrailingCursor(to: result, isVisible: isCursorVisible)
}
return result
}

private static func appendTrailingCursor(to result: NSMutableAttributedString, isVisible: Bool) {
result.append(NSAttributedString(string: " ●", attributes: [
.font: NSFont.systemFont(ofSize: ClaudeTheme.messageSize(8), weight: .regular),
.foregroundColor: isVisible ? NSColor(ClaudeTheme.accent) : NSColor.clear
]))
}
}

private final class InlineCodeLayoutManager: NSLayoutManager, @unchecked Sendable {
nonisolated override init() {
super.init()
}

nonisolated required init?(coder: NSCoder) {
super.init(coder: coder)
}

nonisolated override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
guard let storage = textStorage,
let container = textContainers.first else {
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
return
}

let fullRange = NSRange(location: 0, length: storage.length)
storage.enumerateAttribute(inlineCodeBackgroundAttribute, in: fullRange) { value, charRange, _ in
guard value != nil else { return }
let glyphRange = self.glyphRange(forCharacterRange: charRange, actualCharacterRange: nil)
let visibleRange = NSIntersectionRange(glyphRange, glyphsToShow)
guard visibleRange.length > 0 else { return }

self.enumerateEnclosingRects(
forGlyphRange: glyphRange,
withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0),
in: container
) { rect, _ in
var roundedRect = rect.offsetBy(dx: origin.x, dy: origin.y)
roundedRect = roundedRect.insetBy(dx: -5, dy: -1.5)
let path = NSBezierPath(roundedRect: roundedRect, xRadius: 6, yRadius: 6)
MainActor.assumeIsolated {
NSColor(ClaudeTheme.codeBackground).setFill()
}
path.fill()
}
}

super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
}
}

private final class InlineCodeTextView: NSTextView {
init() {
let storage = NSTextStorage()
let layoutManager = InlineCodeLayoutManager()
let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
container.lineFragmentPadding = 0
container.widthTracksTextView = true
container.heightTracksTextView = false
storage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(container)
super.init(frame: .zero, textContainer: container)
}

required init?(coder: NSCoder) {
nil
}

override var intrinsicContentSize: NSSize {
guard let layoutManager, let textContainer else {
return NSSize(width: NSView.noIntrinsicMetric, height: 0)
}
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height))
}

override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
textContainer?.containerSize = NSSize(width: newSize.width, height: CGFloat.greatestFiniteMagnitude)
invalidateIntrinsicContentSize()
}
}

/// Removes incorrectly included characters (such as backticks) from URLs inside markdown links `[text](url)`
func sanitizeMarkdownLinkURLs(_ text: String) -> String {
let pattern = #"\[([^\]]*)\]\(([^)]*`[^)]*)\)"#
Expand Down Expand Up @@ -586,9 +778,7 @@ private struct BlockquoteView: View {
let content: AttributedString

var body: some View {
Text(content)
.textSelection(.enabled)
.lineSpacing(6)
MarkdownAttributedTextView(content: content)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 13)
.overlay(alignment: .leading) {
Expand Down
Loading