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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,9 @@ sparkle.key
release_notes.html
release_notes.md

relay-server/rxcode-relay

# Relay env files and APNs auth keys (contain secrets)
relay-server/.env
relay-server/.env.*
relay-server/*.p8
13 changes: 12 additions & 1 deletion Packages/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import PackageDescription
let package = Package(
name: "RxCodePackages",
defaultLocalization: "en",
platforms: [.macOS(.v15)],
platforms: [.macOS(.v15), .iOS(.v18)],
products: [
.library(name: "RxCodeCore", targets: ["RxCodeCore"]),
.library(name: "RxCodeChatKit", targets: ["RxCodeChatKit"]),
.library(name: "RxCodeSync", targets: ["RxCodeSync"]),
],
dependencies: [
.package(url: "https://github.com/nalexn/ViewInspector", from: "0.10.0"),
Expand All @@ -28,6 +29,11 @@ let package = Package(
.defaultIsolation(MainActor.self),
]
),
.target(
name: "RxCodeSync",
dependencies: ["RxCodeCore"],
path: "Sources/RxCodeSync"
),
.testTarget(
name: "RxCodeCoreTests",
dependencies: ["RxCodeCore"],
Expand All @@ -42,5 +48,10 @@ let package = Package(
],
path: "Tests/RxCodeChatKitTests"
),
.testTarget(
name: "RxCodeSyncTests",
dependencies: ["RxCodeSync"],
path: "Tests/RxCodeSyncTests"
),
]
)
3 changes: 3 additions & 0 deletions Packages/Sources/RxCodeChatKit/AttachmentPreviewItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import SwiftUI
import LinkPresentation
import RxCodeCore

#if os(macOS)

/// Attachment preview item — tall rectangular card
/// Shows an X button overlay on mouse hover
struct AttachmentPreviewItem: View {
Expand Down Expand Up @@ -242,3 +244,4 @@ struct AttachmentPreviewItem: View {
}
}
}
#endif
9 changes: 9 additions & 0 deletions Packages/Sources/RxCodeChatKit/BashTerminalSheet.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import SwiftUI
import RxCodeCore
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif

/// Terminal-styled sheet that surfaces the output of a completed `Bash` tool call.
/// Mirrors the look of the interactive terminal popup (dark background, monospaced
Expand Down Expand Up @@ -122,8 +127,12 @@ struct BashTerminalSheet: View {
}

private func copyResult() {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(resultText, forType: .string)
#elseif os(iOS)
UIPasteboard.general.string = resultText
#endif
}
}
16 changes: 8 additions & 8 deletions Packages/Sources/RxCodeChatKit/ChatBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,10 @@ public final class ChatBridge {
let message = messages[messageIdx]
for blockIdx in message.blocks.indices.reversed() {
guard let toolCall = message.blocks[blockIdx].toolCall,
PlanCardView.isExitPlanMode(toolCall) else { continue }
PlanLogic.isExitPlanMode(toolCall) else { continue }

if planDecisionSummaries[toolCall.id] != nil { return false }
if PlanCardView.isPlanDecided(toolCall) { return false }
if PlanLogic.isPlanDecided(toolCall) { return false }

// If anything came after this ExitPlanMode — later blocks in the
// same message, or any later message — the chat continued past
Expand All @@ -154,24 +154,24 @@ public final class ChatBridge {
for message in messages {
for block in message.blocks {
guard let toolCall = block.toolCall,
PlanCardView.isExitPlanMode(toolCall) else { continue }
if PlanCardView.isSupersededExitPlanMode(
PlanLogic.isExitPlanMode(toolCall) else { continue }
if PlanLogic.isSupersededExitPlanMode(
toolCall: toolCall,
in: message,
allMessages: messages
) { continue }

let inlineMd = toolCall.input["plan"]?.stringValue ?? ""
let markdown: String = inlineMd.isEmpty
? (PlanCardView.fallbackPlanMarkdown(in: message)
?? PlanCardView.latestPriorPlanMarkdown(before: message, in: messages)
? (PlanLogic.fallbackPlanMarkdown(in: message)
?? PlanLogic.latestPriorPlanMarkdown(before: message, in: messages)
?? "")
: inlineMd

let persistedSummary = planDecisionSummaries[toolCall.id]
let decided = persistedSummary != nil || PlanCardView.isPlanDecided(toolCall)
let decided = persistedSummary != nil || PlanLogic.isPlanDecided(toolCall)
let summary: String? = persistedSummary
?? (PlanCardView.isPlanDecided(toolCall) ? toolCall.result : nil)
?? (PlanLogic.isPlanDecided(toolCall) ? toolCall.result : nil)
let isStreaming = message.isStreaming && markdown.isEmpty
collected.append(PendingPlan(
toolCallId: toolCall.id,
Expand Down
213 changes: 213 additions & 0 deletions Packages/Sources/RxCodeChatKit/ChatMessageBubble.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import SwiftUI
import RxCodeCore

public struct ChatMessageBubble: View {
let message: ChatMessage

public init(message: ChatMessage) {
self.message = message
}

public var body: some View {
#if os(macOS)
MessageBubble(message: message)
#else
CompactChatMessageBubble(message: message)
#endif
}
}

#if !os(macOS)
private struct CompactChatMessageBubble: View {
let message: ChatMessage

private enum RenderBlock: Identifiable {
case text(MessageBlock)
case tool(ToolCall)
case transientTools(id: String, tools: [ToolCall])

var id: String {
switch self {
case .text(let block):
return block.id
case .tool(let toolCall):
return toolCall.id
case .transientTools(let id, _):
return id
}
}
}

var body: some View {
HStack(alignment: .top) {
if message.role == .user { Spacer(minLength: 40) }
VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 8) {
if message.role == .user {
let displayed = ChatSession.extractDisplayedContent(from: message.content)
if !displayed.text.isEmpty {
userTextBubble(displayed.text)
}
} else if message.isCompactBoundary {
compactBoundaryBubble
} else if message.isError {
errorBubble
} else {
ForEach(renderBlocks) { block in
blockView(block)
}

if !message.isStreaming, let duration = message.duration {
HStack(spacing: 4) {
if message.isResponseComplete {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: ClaudeTheme.messageSize(11)))
.foregroundStyle(ClaudeTheme.statusSuccess)
}
Text(duration.formattedDuration)
.font(.system(size: ClaudeTheme.messageSize(11), design: .monospaced))
.foregroundStyle(ClaudeTheme.textTertiary)
}
}
}
}
if message.role == .assistant { Spacer(minLength: 40) }
}
}

@ViewBuilder
private func blockView(_ block: RenderBlock) -> some View {
switch block {
case .text(let messageBlock):
if let text = messageBlock.text, !text.isEmpty {
assistantText(text)
}
case .tool(let toolCall):
ToolResultView(toolCall: toolCall, isMessageStreaming: message.isStreaming)
case .transientTools(_, let tools):
ChatTransientToolSummaryView(tools: tools)
}
}

private func userTextBubble(_ text: String) -> some View {
ChatTextContentView(
text,
size: ClaudeTheme.messageSize(14),
color: ClaudeTheme.userBubbleText,
lineSpacing: 2
)
.bubbleStyle(.user)
.frame(maxWidth: 500, alignment: .trailing)
}

private func assistantText(_ text: String) -> some View {
ChatTextContentView(
markdown: text,
size: ClaudeTheme.messageSize(14),
color: ClaudeTheme.textPrimary,
lineSpacing: 2
)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
}

private var compactBoundaryBubble: some View {
HStack(spacing: 8) {
Image(systemName: "arrow.trianglehead.2.clockwise")
.font(.system(size: ClaudeTheme.messageSize(12), weight: .medium))
.foregroundStyle(ClaudeTheme.textTertiary)
ChatTextContentView(
message.content,
size: ClaudeTheme.messageSize(13),
weight: .medium,
color: ClaudeTheme.textTertiary
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.padding(.horizontal, 14)
.background(
RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)
.fill(ClaudeTheme.surfacePrimary)
)
.overlay(
RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)
.strokeBorder(ClaudeTheme.border, lineWidth: BubbleStyle.borderWidth)
)
}

private var errorBubble: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: ClaudeTheme.messageSize(13)))
.foregroundStyle(ClaudeTheme.statusWarning)
ChatTextContentView(
message.content,
size: ClaudeTheme.messageSize(14),
color: ClaudeTheme.textPrimary
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.bubbleStyle(.error)
}

private var renderBlocks: [RenderBlock] {
var result: [RenderBlock] = []
var pendingTransientTools: [ToolCall] = []
var pendingTransientGroupStartId: String?

func appendText(_ block: MessageBlock) {
guard let text = block.text, !text.isEmpty else { return }
if let lastIndex = result.indices.last,
case .text(let previousBlock) = result[lastIndex] {
let previous = previousBlock.text ?? ""
let needsSpace = !(previous.last?.isWhitespace ?? true) && !(text.first?.isWhitespace ?? true)
let joined = needsSpace ? previous + " " + text : previous + text
result[lastIndex] = .text(.text(joined, id: previousBlock.id))
} else {
result.append(.text(block))
}
}

func flushTransientTools() {
guard !pendingTransientTools.isEmpty else { return }
let startId = pendingTransientGroupStartId ?? pendingTransientTools[0].id
result.append(.transientTools(
id: "transient-tools-\(startId)-\(pendingTransientTools.count)",
tools: pendingTransientTools
))
pendingTransientTools = []
pendingTransientGroupStartId = nil
}

for block in message.blocks {
if block.isText {
flushTransientTools()
appendText(block)
continue
}

guard let toolCall = block.toolCall else { continue }
if message.isStreaming {
flushTransientTools()
result.append(.tool(toolCall))
continue
}

if ToolCategory(toolName: toolCall.name).isTransient {
guard toolCall.result != nil || toolCall.isError else { continue }
if pendingTransientGroupStartId == nil {
pendingTransientGroupStartId = toolCall.id
}
pendingTransientTools.append(toolCall)
} else if toolCall.isKeepAlways || toolCall.result != nil || toolCall.isError {
flushTransientTools()
result.append(.tool(toolCall))
}
}

flushTransientTools()
return result
}
}
#endif
Loading