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
103 changes: 103 additions & 0 deletions Packages/Sources/RxCodeChatKit/ChangeDiffView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import SwiftUI
import RxCodeCore

/// Full, non-collapsing diff renderer for a single file. Used by the mobile
/// "View Changes" detail page, which owns a whole screen and therefore renders
/// every diff line. Accepts either a raw unified diff string (git changes) or a
/// set of old/new edit-hunk pairs (thread file edits).
///
/// The +/- coloring intentionally mirrors `ToolResultView`'s inline chat diffs.
public struct ChangeDiffView: View {
private enum Source {
case unified(String)
case hunks([PreviewFile.EditHunk])
}

private let source: Source

/// Renders a raw unified diff, e.g. `git diff` output.
public init(unifiedDiff: String) {
source = .unified(unifiedDiff)
}

/// Renders old/new replacement pairs as a removed-then-added diff.
public init(hunks: [PreviewFile.EditHunk]) {
source = .hunks(hunks)
}

public var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch source {
case .unified(let diff):
unifiedRows(diff)
case .hunks(let hunks):
hunkRows(hunks)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}

// MARK: - Unified diff

@ViewBuilder
private func unifiedRows(_ diff: String) -> some View {
let lines = diff.components(separatedBy: .newlines)
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
diffRow(
text: line.isEmpty ? " " : line,
color: unifiedColor(line),
background: unifiedBackground(line)
)
}
}

// MARK: - Edit hunks

@ViewBuilder
private func hunkRows(_ hunks: [PreviewFile.EditHunk]) -> some View {
ForEach(Array(hunks.enumerated()), id: \.offset) { index, hunk in
if index > 0 {
Divider().padding(.vertical, 4)
}
let removed = hunk.oldString
.components(separatedBy: .newlines)
.map { ("- " + $0, ClaudeTheme.statusError, ClaudeTheme.statusError.opacity(0.06)) }
let added = hunk.newString
.components(separatedBy: .newlines)
.map { ("+ " + $0, ClaudeTheme.statusSuccess, ClaudeTheme.statusSuccess.opacity(0.06)) }
ForEach(Array((removed + added).enumerated()), id: \.offset) { _, item in
diffRow(text: item.0, color: item.1, background: item.2)
}
}
}

// MARK: - Shared row

private func diffRow(text: String, color: Color, background: Color) -> some View {
ChatTextContentView(
text,
size: ClaudeTheme.messageSize(12),
design: .monospaced,
color: color
)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 1)
.background(background)
}

private func unifiedColor(_ line: String) -> Color {
if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess }
if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError }
if line.hasPrefix("@@") { return ClaudeTheme.accent }
return ClaudeTheme.textPrimary
}

private func unifiedBackground(_ line: String) -> Color {
if line.hasPrefix("+"), !line.hasPrefix("+++") { return ClaudeTheme.statusSuccess.opacity(0.06) }
if line.hasPrefix("-"), !line.hasPrefix("---") { return ClaudeTheme.statusError.opacity(0.06) }
if line.hasPrefix("@@") { return ClaudeTheme.accent.opacity(0.08) }
return Color.clear
}
}
22 changes: 22 additions & 0 deletions Packages/Sources/RxCodeChatKit/FeatureTips.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import SwiftUI
import TipKit

enum ChatFeatureTips {
struct PlanModeTip: Tip {
var title: Text {
Text("Start in plan mode")
}

var message: Text? {
Text("Use the Add menu or Shift-Tab to ask the agent for a read-only plan before edits begin.")
}

var image: Image? {
Image(systemName: "checklist")
}

var options: [any TipOption] {
Tips.MaxDisplayCount(1)
}
}
}
2 changes: 2 additions & 0 deletions Packages/Sources/RxCodeChatKit/InputBarView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import TipKit
import UniformTypeIdentifiers
import RxCodeCore

Expand Down Expand Up @@ -246,6 +247,7 @@ struct InputBarView<Accessory: View, TopAccessory: View>: View {
.fixedSize()
.help(windowState.sessionPlanMode ? "Plan mode is on — Add menu" : "Add — attach file or toggle plan mode")
.accessibilityIdentifier("composer-add-menu")
.popoverTip(ChatFeatureTips.PlanModeTip(), arrowEdge: .top)
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.item],
Expand Down
43 changes: 9 additions & 34 deletions Packages/Sources/RxCodeChatKit/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ struct MarkdownContentView: View {
let showsTrailingCursor: Bool
let isCursorVisible: Bool

/// `true` while the chat list is scrolling — see `markdownTextSelection`.
@Environment(\.chatListScrollActive) private var isScrollActive

init(text: String, showsTrailingCursor: Bool = false, isCursorVisible: Bool = true) {
self.text = text
self.showsTrailingCursor = showsTrailingCursor
Expand All @@ -37,7 +34,15 @@ struct MarkdownContentView: View {
)
.textual.headingStyle(RxCodeHeadingStyle())
.textual.codeBlockStyle(RxCodeBlockStyle())
.markdownTextSelection(enabled: !isScrollActive)
// Keep Textual's text-selection overlay permanently disabled.
// When enabled it installs a geometry-dependent
// `onChange(of: AnyTextLayoutCollection)` that fires many times
// per frame while the chat List scrolls, dropping frames and
// making the scroll bumpy. Toggling it per scroll-phase is worse:
// flipping selectability swaps Textual's view-tree branch and
// rebuilds every visible markdown row. Whole-message and
// per-code-block Copy buttons cover copying instead.
.textual.textSelection(.disabled)
.frame(maxWidth: .infinity, alignment: .leading)
}

Expand All @@ -52,36 +57,6 @@ struct MarkdownContentView: View {
}
}

// MARK: - Text Selection Toggle

extension EnvironmentValues {
/// `true` while the chat message list is actively scrolling.
///
/// Markdown rows read this to drop Textual's text-selection overlay
/// mid-scroll — see `View.markdownTextSelection(enabled:)`.
@Entry var chatListScrollActive: Bool = false
}

private extension View {
/// Applies Textual text selection, gated by `enabled`.
///
/// Textual's selection overlay installs a per-message `Text.LayoutKey`
/// preference observer whose `onChange` mutates an `@Observable` model on
/// every layout pass. While a `List` scrolls, that fires repeatedly within
/// a single frame ("onChange(of: AnyTextLayoutCollection) ... tried to
/// update multiple times per frame"), dropping frames and making the
/// scroll bumpy. Suspending selection during scroll removes the overlay
/// entirely; it is restored the instant the list settles.
@ViewBuilder
func markdownTextSelection(enabled: Bool) -> some View {
if enabled {
textual.textSelection(.enabled)
} else {
textual.textSelection(.disabled)
}
}
}

// MARK: - Markdown Preprocessing

/// Applies bare-URL auto-linking and link sanitization, skipping fenced code blocks
Expand Down
9 changes: 0 additions & 9 deletions Packages/Sources/RxCodeChatKit/MessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ struct MessageListView: View {
@State private var readyTask: Task<Void, Never>?
@State private var anchor = AutoScrollAnchor()
@State private var isSessionReady = false
/// Tracks active scrolling so markdown rows can suspend Textual's
/// text-selection overlay mid-scroll (avoids per-frame layout cycles).
@State private var isScrollActive = false

private static let log = Logger(subsystem: "com.claudework", category: "MessageListView")
private static let bottomAnchorID = "message-list-bottom-anchor"
Expand Down Expand Up @@ -71,7 +68,6 @@ struct MessageListView: View {
.contentMargins(.top, 16, for: .scrollContent)
.scrollContentBackground(.hidden)
.environment(\.defaultMinListRowHeight, 0)
.environment(\.chatListScrollActive, isScrollActive)
.opacity(isSessionReady ? 1 : 0)
.defaultScrollAnchor(.bottom)
.onScrollGeometryChange(for: ScrollSample.self) { geo in
Expand All @@ -86,11 +82,6 @@ struct MessageListView: View {
scrollToBottomDebounced(proxy)
}
}
.onScrollPhaseChange { _, newPhase in
// Suspend Textual text selection while the list is in motion and
// restore it the instant scrolling settles back to `.idle`.
isScrollActive = newPhase != .idle
}
.task(id: windowState.currentSessionId) {
let sid = windowState.currentSessionId ?? "<nil>"
Self.log.info("[MessageList.task] fired sid=\(sid, privacy: .public) bridgeMessages=\(chatBridge.messages.count) isStreaming=\(chatBridge.isStreaming) isLoadingFromDisk=\(chatBridge.isLoadingFromDisk)")
Expand Down
5 changes: 5 additions & 0 deletions Packages/Sources/RxCodeCore/Backend/AgentBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public struct BackendSendRequest: Sendable {
public let hookSettingsPath: String?
/// Path to the Claude MCP config JSON written for this turn. Claude-only.
public let mcpClaudeConfigPath: String?
/// Extra text appended to the agent's system prompt for this turn — e.g. the
/// accumulated briefing for the project's current branch. Claude-only.
public let extraSystemPrompt: String?
/// `-c` overrides handed to the Codex app-server child. Codex-only.
public let mcpCodexOverrides: [String]
/// JSON-RPC payload for ACP's `session/new` `mcpServers` parameter.
Expand All @@ -39,6 +42,7 @@ public struct BackendSendRequest: Sendable {
planMode: Bool = false,
hookSettingsPath: String? = nil,
mcpClaudeConfigPath: String? = nil,
extraSystemPrompt: String? = nil,
mcpCodexOverrides: [String] = [],
acpMCPServers: [JSONValue] = [],
acpSpec: ACPClientSpec? = nil,
Expand All @@ -54,6 +58,7 @@ public struct BackendSendRequest: Sendable {
self.planMode = planMode
self.hookSettingsPath = hookSettingsPath
self.mcpClaudeConfigPath = mcpClaudeConfigPath
self.extraSystemPrompt = extraSystemPrompt
self.mcpCodexOverrides = mcpCodexOverrides
self.acpMCPServers = acpMCPServers
self.acpSpec = acpSpec
Expand Down
7 changes: 4 additions & 3 deletions Packages/Sources/RxCodeCore/Backend/BackendCapability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum BackendCapability: String, Sendable, Hashable, CaseIterable, Codable
case attachments
case hooks
case mcpServers
case skills
}

public typealias CapabilitySet = Set<BackendCapability>
Expand All @@ -26,16 +27,16 @@ public extension AgentProvider {
case .claudeCode:
return [
.askUserQuestion, .todos, .planMode, .fileEdit, .hooks,
.mcpServers, .attachments, .customSlashCommands, .getUsage,
.mcpServers, .skills, .attachments, .customSlashCommands, .getUsage,
]
case .codex:
return [
.askUserQuestion, .todos, .planMode, .fileEdit,
.mcpServers, .attachments, .getUsage,
.mcpServers, .skills, .attachments, .getUsage,
]
case .acp:
return [
.planMode, .fileEdit, .mcpServers, .attachments, .getUsage,
.planMode, .fileEdit, .mcpServers, .skills, .attachments, .getUsage,
]
}
}
Expand Down
14 changes: 14 additions & 0 deletions Packages/Sources/RxCodeCore/Models/GitHubModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ public struct DeviceCodeResponse: Codable, Sendable {
}
}

// MARK: - Custom Git Repository

public struct CustomRepo: Identifiable, Codable, Sendable, Hashable {
public let id: UUID
public var name: String
public var cloneURL: String

public init(id: UUID = UUID(), name: String, cloneURL: String) {
self.id = id
self.name = name
self.cloneURL = cloneURL
}
}

// MARK: - Device Flow: Access Token Response

public struct AccessTokenResponse: Codable, Sendable {
Expand Down
Loading