diff --git a/Packages/Sources/RxCodeChatKit/MessageListView.swift b/Packages/Sources/RxCodeChatKit/MessageListView.swift index e1761e7f..c5bea4e9 100644 --- a/Packages/Sources/RxCodeChatKit/MessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/MessageListView.swift @@ -328,9 +328,10 @@ fileprivate func suppressPlanReadyFollowups(in messages: [ChatMessage]) -> [Chat continue } - if hasRecentPlanCard && isPlanReadyFollowupMessage(message) { - continue - } + // Plan-ready follow-up messages (e.g. "Plan is ready at /…/plans/foo.md") + // are intentionally kept visible alongside the plan card so the user + // sees the summary while approval is pending. + _ = hasRecentPlanCard if PlanCardView.containsExitPlanMode(message) { hasRecentPlanCard = true diff --git a/Packages/Sources/RxCodeChatKit/PlanCardView.swift b/Packages/Sources/RxCodeChatKit/PlanCardView.swift index 64e39fbf..a5668a14 100644 --- a/Packages/Sources/RxCodeChatKit/PlanCardView.swift +++ b/Packages/Sources/RxCodeChatKit/PlanCardView.swift @@ -156,9 +156,10 @@ struct PlanCardView: View { allMessages: [ChatMessage] = [] ) -> Bool { let hasExitPlanInMessage = containsExitPlanMode(message) - if let text = block.text { - return hasExitPlanInMessage && isPlanReadyFollowup(text) - } + // Text blocks (including the model's "Plan is ready at /path/foo.md" + // follow-up) are intentionally NOT hidden — they render alongside the + // plan card so the user sees the summary while approval is pending. + if block.text != nil { return false } guard let toolCall = block.toolCall, isPlanFileWrite(toolCall) else { return false } // Hide a plan-file Write when the ExitPlanMode card is present anywhere in // the same assistant run — not just in this exact message. Two cards otherwise diff --git a/Packages/Sources/RxCodeChatKit/StatusLineView.swift b/Packages/Sources/RxCodeChatKit/StatusLineView.swift index 99bee5f1..96c51c19 100644 --- a/Packages/Sources/RxCodeChatKit/StatusLineView.swift +++ b/Packages/Sources/RxCodeChatKit/StatusLineView.swift @@ -91,6 +91,7 @@ struct StatusLineView: View { switch chatBridge.agentProvider { case .claudeCode: return chatBridge.claudeVersion case .codex: return chatBridge.codexVersion + case .acp: return nil } } @@ -98,6 +99,7 @@ struct StatusLineView: View { switch chatBridge.agentProvider { case .claudeCode: return "CC" case .codex: return "Codex" + case .acp: return "ACP" } } @@ -176,6 +178,8 @@ struct StatusLineView: View { title: "24-hour rate limit", body: "Tracks usage against Codex's rolling 24-hour limit. Resets gradually as older requests age out." ) + case .acp: + EmptyView() } } diff --git a/Packages/Sources/RxCodeCore/Models/ACPClient.swift b/Packages/Sources/RxCodeCore/Models/ACPClient.swift new file mode 100644 index 00000000..75f7e6f6 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/ACPClient.swift @@ -0,0 +1,224 @@ +import Foundation + +// MARK: - Registry Schema +// Mirrors the JSON schema served at +// https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json + +public struct ACPRegistry: Codable, Sendable { + public var version: String + public var agents: [ACPRegistryAgent] + public var extensions: [ACPRegistryExtension]? + + public init(version: String, agents: [ACPRegistryAgent], extensions: [ACPRegistryExtension]? = nil) { + self.version = version + self.agents = agents + self.extensions = extensions + } +} + +public struct ACPRegistryAgent: Codable, Identifiable, Hashable, Sendable { + public var id: String + public var name: String + public var version: String + public var description: String + public var repository: String? + public var website: String? + public var authors: [String]? + public var license: String? + public var icon: String? + public var distribution: ACPDistribution + + public init( + id: String, + name: String, + version: String, + description: String, + repository: String? = nil, + website: String? = nil, + authors: [String]? = nil, + license: String? = nil, + icon: String? = nil, + distribution: ACPDistribution + ) { + self.id = id + self.name = name + self.version = version + self.description = description + self.repository = repository + self.website = website + self.authors = authors + self.license = license + self.icon = icon + self.distribution = distribution + } +} + +public struct ACPRegistryExtension: Codable, Hashable, Sendable { + public var id: String + public var name: String? + public var description: String? +} + +public struct ACPDistribution: Codable, Hashable, Sendable { + public var npx: ACPNpxDist? + public var uvx: ACPUvxDist? + /// Keyed by platform: darwin-aarch64, darwin-x86_64, linux-*, windows-* + public var binary: [String: ACPBinaryDist]? + + public init(npx: ACPNpxDist? = nil, uvx: ACPUvxDist? = nil, binary: [String: ACPBinaryDist]? = nil) { + self.npx = npx + self.uvx = uvx + self.binary = binary + } +} + +public struct ACPNpxDist: Codable, Hashable, Sendable { + public var package: String + public var args: [String]? + public var env: [String: String]? +} + +public struct ACPUvxDist: Codable, Hashable, Sendable { + public var package: String + public var args: [String]? + public var env: [String: String]? +} + +public struct ACPBinaryDist: Codable, Hashable, Sendable { + public var archive: String + public var cmd: String + public var args: [String]? + public var env: [String: String]? +} + +// MARK: - Installed Client + +/// User-installed instance of an ACP agent. Persisted to +/// `Application Support/RxCode/acp_clients.json`. +public struct ACPClientSpec: Codable, Identifiable, Hashable, Sendable { + public var id: String + /// `ACPRegistryAgent.id` this was installed from (if any). + public var registryId: String? + public var displayName: String + public var enabled: Bool + /// Method by which this client is launched. + public var launch: LaunchKind + /// Model IDs surfaced in the model picker. Populated by probing the agent + /// at install time (and refreshed on every session start), with manual + /// entry as a fallback for agents that don't advertise a model selector. + public var models: [String] + /// Full model options advertised by the ACP client. `models` is kept as a + /// lightweight compatibility list of option values, while this stores the + /// human-readable names shown in the picker. + public var modelOptions: [ACPModelOption]? + /// `SessionConfigId` of the model selector exposed by the agent's + /// `session/new` response. Present iff the agent advertises a + /// `category: "model"` config option; used to issue + /// `session/set_config_option` for live model switching. + public var modelConfigId: String? + /// Env var the agent reads the model ID from (e.g. `ANTHROPIC_MODEL`). + /// When set, RxCode sets this var to the selected model at spawn time. + /// Used as a fallback for agents that don't expose `modelConfigId`. + public var modelEnvVar: String? + public var extraEnv: [String: String] + public var extraArgs: [String] + public var iconURL: String? + + public init( + id: String = UUID().uuidString, + registryId: String? = nil, + displayName: String, + enabled: Bool = true, + launch: LaunchKind, + models: [String] = [], + modelOptions: [ACPModelOption]? = nil, + modelConfigId: String? = nil, + modelEnvVar: String? = nil, + extraEnv: [String: String] = [:], + extraArgs: [String] = [], + iconURL: String? = nil + ) { + self.id = id + self.registryId = registryId + self.displayName = displayName + self.enabled = enabled + self.launch = launch + self.models = models + self.modelOptions = modelOptions + self.modelConfigId = modelConfigId + self.modelEnvVar = modelEnvVar + self.extraEnv = extraEnv + self.extraArgs = extraArgs + self.iconURL = iconURL + } + + public enum LaunchKind: Codable, Hashable, Sendable { + /// Run via `npx -y ` (Node required on PATH). + case npx(package: String, args: [String], env: [String: String]) + /// Run via `uvx ` (uv required on PATH). + case uvx(package: String, args: [String], env: [String: String]) + /// Run a binary already on disk at `path`. + case binary(path: String, args: [String], env: [String: String]) + /// Run an arbitrary user-supplied command. + case custom(command: String, args: [String], env: [String: String]) + + public var displayKind: String { + switch self { + case .npx: return "npx" + case .uvx: return "uvx" + case .binary: return "binary" + case .custom: return "custom" + } + } + } +} + +// MARK: - Platform Helpers + +public enum ACPPlatform { + /// Returns the registry platform key matching the running host, or nil if unsupported. + public static var current: String { + #if arch(arm64) + return "darwin-aarch64" + #elseif arch(x86_64) + return "darwin-x86_64" + #else + return "darwin-unknown" + #endif + } +} + +// MARK: - Model Discovery + +/// A single model exposed by an agent's `session/new` configOptions entry. +/// Mirrors `SessionConfigSelectOption` in the ACP schema. +public struct ACPModelOption: Codable, Hashable, Sendable { + /// `SessionConfigValueId` — the model id passed back to + /// `session/set_config_option`. + public var value: String + /// Human-readable label. + public var name: String + public var description: String? + + public init(value: String, name: String, description: String? = nil) { + self.value = value + self.name = name + self.description = description + } +} + +/// Model selector discovered from an agent's `session/new` response — a +/// `SessionConfigOption` with `category: "model"` and `type: "select"`. +public struct ACPModelConfig: Codable, Hashable, Sendable { + /// `SessionConfigId` used to switch the model via + /// `session/set_config_option`. + public var configId: String + public var currentValue: String? + public var options: [ACPModelOption] + + public init(configId: String, currentValue: String? = nil, options: [ACPModelOption]) { + self.configId = configId + self.currentValue = currentValue + self.options = options + } +} diff --git a/Packages/Sources/RxCodeCore/Models/AgentModel.swift b/Packages/Sources/RxCodeCore/Models/AgentModel.swift index cc646550..117b9d2d 100644 --- a/Packages/Sources/RxCodeCore/Models/AgentModel.swift +++ b/Packages/Sources/RxCodeCore/Models/AgentModel.swift @@ -3,11 +3,24 @@ import Foundation public enum AgentProvider: String, Codable, CaseIterable, Sendable, Hashable { case claudeCode case codex + case acp public var displayName: String { switch self { case .claudeCode: return "Claude Code" case .codex: return "Codex" + case .acp: return "ACP" + } + } + + /// Default `SessionOrigin` for sessions originated by this provider. + /// Used when creating placeholder summaries and computing the persistence + /// route for newly-saved sessions. + public var defaultSessionOrigin: SessionOrigin { + switch self { + case .claudeCode: return .cliBacked + case .codex: return .codexAppServer + case .acp: return .acpAgent } } } diff --git a/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift b/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift index 32250deb..dffa1042 100644 --- a/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift +++ b/Packages/Sources/RxCodeCore/Models/SessionOrigin.swift @@ -13,4 +13,11 @@ public enum SessionOrigin: String, Codable, Sendable { /// Backed by Codex app-server for execution. RxCode persists a local replay /// copy of the rendered messages while using the Codex thread id for turns. case codexAppServer + + /// Backed by an Agent Client Protocol agent (OpenCode, Gemini CLI, etc.). + /// The agent owns its own session storage on disk, but RxCode also keeps a + /// local replay copy of the rendered messages — the agents do not expose + /// their logs in a format RxCode can read, so without this copy the chat + /// appears empty on reopen. + case acpAgent } diff --git a/Packages/Sources/RxCodeCore/Models/StreamEvent.swift b/Packages/Sources/RxCodeCore/Models/StreamEvent.swift index 558b56a9..230e7509 100644 --- a/Packages/Sources/RxCodeCore/Models/StreamEvent.swift +++ b/Packages/Sources/RxCodeCore/Models/StreamEvent.swift @@ -9,9 +9,25 @@ public enum StreamEvent: Sendable { case result(ResultEvent) case rateLimitEvent(RateLimitInfo) case todoSnapshot(TodoSnapshotEvent) + case acpModelsDiscovered(ACPModelsDiscoveredEvent) case unknown(String) } +// MARK: - ACP Models Discovered + +/// Emitted after `session/new` succeeds when the agent advertises a model +/// selector. AppState writes the discovered list back to the matching +/// `ACPClientSpec` so the picker stays in sync with the agent. +public struct ACPModelsDiscoveredEvent: Sendable { + public let clientId: String + public let config: ACPModelConfig + + public init(clientId: String, config: ACPModelConfig) { + self.clientId = clientId + self.config = config + } +} + // MARK: - System Event public struct SystemEvent: Sendable { diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index c3cec223..a71aa418 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -1,8 +1,8 @@ import Foundation -import RxCodeCore -import SwiftUI import os import RxCodeChatKit +import RxCodeCore +import SwiftUI // MARK: - Per-Session Stream State @@ -27,7 +27,7 @@ struct SessionStreamState { // Streaming var isStreaming = false var isThinking = false - var needsNewMessage = false // After receiving .user(tool result), the next block starts a new ChatMessage + var needsNewMessage = false // After receiving .user(tool result), the next block starts a new ChatMessage var activeStreamId: UUID? var streamingStartDate: Date? var streamTask: Task? @@ -39,12 +39,14 @@ struct SessionStreamState { var flushTask: Task? // tool_use input streaming buffer - var activeToolId: String? // tool_use id currently receiving input_json_delta - var activeToolInputBuffer: String = "" // accumulator for input_json_delta + var activeToolId: String? // tool_use id currently receiving input_json_delta + var activeToolInputBuffer: String = "" // accumulator for input_json_delta // Per-session overrides (persisted in memory across session switches) var agentProvider: AgentProvider? var model: String? + /// Identifies which `ACPClientSpec` to use when `agentProvider == .acp`. + var acpClientId: String? var effort: String? var permissionMode: PermissionMode? /// Per-session plan-mode toggle. When true, the CLI is launched with `--permission-mode plan` @@ -82,6 +84,7 @@ struct SessionStreamState { var currentTurnOutputTokens: Int { currentTurnOutputTokensByMessage.values.reduce(0, +) + currentTurnOutputTokensUnkeyed } + var lastTurnContextUsedPercentage: Double? var activeModelName: String? @@ -105,11 +108,9 @@ enum SummarizationProvider: String, CaseIterable, Identifiable { } } - @Observable @MainActor final class AppState { - // MARK: - Logger private let logger = Logger(subsystem: "com.claudework", category: "AppState") @@ -141,13 +142,14 @@ final class AppState { // MARK: - Theme - var selectedTheme: AppTheme = AppTheme(rawValue: UserDefaults.standard.string(forKey: "selectedTheme") ?? "") ?? .claude { + var selectedTheme: AppTheme = .init(rawValue: UserDefaults.standard.string(forKey: "selectedTheme") ?? "") ?? .claude { didSet { UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme") ThemeStore.shared.current = selectedTheme themeRevision += 1 } } + /// Incrementing causes NavigationSplitView to rebuild and immediately apply theme colors var themeRevision: Int = 0 @@ -210,11 +212,110 @@ final class AppState { } } - func availableAgentModelSections() -> [(provider: AgentProvider, models: [AgentModel])] { - [ - (.claudeCode, Self.availableClaudeModels), - (.codex, Self.availableCodexModels(codexModels)) + func availableAgentModelSections() -> [(id: String, title: String, provider: AgentProvider, iconURL: String?, models: [AgentModel])] { + var sections: [(id: String, title: String, provider: AgentProvider, iconURL: String?, models: [AgentModel])] = [ + ("claudeCode", AgentProvider.claudeCode.displayName, .claudeCode, nil, Self.availableClaudeModels), + ("codex", AgentProvider.codex.displayName, .codex, nil, Self.availableCodexModels(codexModels)), ] + + // Each enabled ACP client becomes its own section, titled with the + // client's display name (e.g. "gemini-cli"). Model ids are prefixed + // with the client id so the selection round-trips back to the right client. + // When no models were discovered, inject a synthetic "Default" entry + // (empty model id) so the client is still selectable in the picker — + // the agent picks its own default at session start. + for client in acpClients where client.enabled { + let models: [AgentModel] + if let options = client.modelOptions, !options.isEmpty { + models = options.map { option in + AgentModel( + provider: .acp, + id: "\(client.id)::\(option.value)", + displayName: option.name.isEmpty ? option.value : Self.stripACPProviderPrefix(option.name), + description: option.description ?? "ACP client \(client.displayName)" + ) + } + } else if client.models.isEmpty { + models = [AgentModel( + provider: .acp, + id: "\(client.id)::", + displayName: "Default", + description: "ACP client \(client.displayName)" + )] + } else { + models = client.models.map { model in + AgentModel( + provider: .acp, + id: "\(client.id)::\(model)", + displayName: model, + description: "ACP client \(client.displayName)" + ) + } + } + sections.append(("acp:\(client.id)", client.displayName, .acp, client.iconURL, models)) + } + return sections + } + + /// Splits an ACP model key `::` into its parts. + static func splitACPModelKey(_ key: String) -> (clientId: String, model: String)? { + let parts = key.components(separatedBy: "::") + guard parts.count == 2 else { return nil } + return (parts[0], parts[1]) + } + + private func acpSelectionParts(for model: String?) -> (clientId: String, model: String)? { + guard let model, !model.isEmpty else { return nil } + if let parts = Self.splitACPModelKey(model) { + return parts + } + if acpClients.contains(where: { $0.id == model }) { + return (model, "") + } + if !selectedACPClientId.isEmpty { + return (selectedACPClientId, model) + } + if let client = acpClients.first(where: { $0.enabled && ($0.modelOptions?.contains(where: { $0.value == model }) ?? false) }) { + return (client.id, model) + } + if let client = acpClients.first(where: { $0.enabled && $0.models.contains(model) }) { + return (client.id, model) + } + return nil + } + + private func acpModelDisplayName(client: ACPClientSpec, model: String) -> String { + if model.isEmpty { + return "Default" + } + if let option = client.modelOptions?.first(where: { $0.value == model }), + !option.name.isEmpty + { + return Self.stripACPProviderPrefix(option.name) + } + return model + } + + /// Drops the leading `/` segment from an ACP model option name + /// when the picker already shows the client name to the left. Example: + /// `"OpenCode Zen/MiniMax M2.5 Free"` → `"MiniMax M2.5 Free"`. Names + /// without a `/` are returned unchanged. + static func stripACPProviderPrefix(_ name: String) -> String { + guard let slash = name.lastIndex(of: "/") else { return name } + let tail = name[name.index(after: slash)...].trimmingCharacters(in: .whitespaces) + return tail.isEmpty ? name : String(tail) + } + + /// Human-readable label for a model id, resolving ACP keys (`::`) + /// to the client's display name plus the underlying model id ("Default" when empty). + func modelDisplayLabel(_ model: String, provider: AgentProvider) -> String { + if provider == .acp, let parts = acpSelectionParts(for: model) { + guard let client = acpClients.first(where: { $0.id == parts.clientId }) else { + return parts.model.isEmpty ? "ACP · Default" : "ACP · \(parts.model)" + } + return "\(client.displayName) · \(acpModelDisplayName(client: client, model: parts.model))" + } + return Self.modelDisplayName(model, provider: provider) } static func modelDisplayName(_ model: String) -> String { @@ -259,27 +360,28 @@ final class AppState { } let key: String switch model { - case "default": key = "model.desc.default" - case "best": key = "model.desc.best" - case "opus": key = "model.desc.opus" - case "opus[1m]": key = "model.desc.opus1m" - case "opusplan": key = "model.desc.opusplan" - case "sonnet": key = "model.desc.sonnet" + case "default": key = "model.desc.default" + case "best": key = "model.desc.best" + case "opus": key = "model.desc.opus" + case "opus[1m]": key = "model.desc.opus1m" + case "opusplan": key = "model.desc.opusplan" + case "sonnet": key = "model.desc.sonnet" case "sonnet[1m]": key = "model.desc.sonnet1m" - case "haiku": key = "model.desc.haiku" + case "haiku": key = "model.desc.haiku" default: return "" } return NSLocalizedString(key, comment: "") } + static let availableEfforts = ["low", "medium", "high", "xhigh", "max"] static func permissionModeDescription(_ mode: PermissionMode) -> String { let key: String switch mode { - case .default: key = "perm.desc.default" - case .acceptEdits: key = "perm.desc.acceptEdits" - case .plan: key = "perm.desc.plan" - case .auto: key = "perm.desc.auto" + case .default: key = "perm.desc.default" + case .acceptEdits: key = "perm.desc.acceptEdits" + case .plan: key = "perm.desc.plan" + case .auto: key = "perm.desc.auto" case .bypassPermissions: key = "perm.desc.bypassPermissions" } return NSLocalizedString(key, comment: "") @@ -288,12 +390,12 @@ final class AppState { static func effortDescription(_ effort: String) -> String { let key: String switch effort { - case "auto": key = "effort.desc.auto" - case "low": key = "effort.desc.low" + case "auto": key = "effort.desc.auto" + case "low": key = "effort.desc.low" case "medium": key = "effort.desc.medium" - case "high": key = "effort.desc.high" - case "xhigh": key = "effort.desc.xhigh" - case "max": key = "effort.desc.max" + case "high": key = "effort.desc.high" + case "xhigh": key = "effort.desc.xhigh" + case "max": key = "effort.desc.max" default: return "" } return NSLocalizedString(key, comment: "") @@ -303,19 +405,32 @@ final class AppState { didSet { UserDefaults.standard.set(selectedModel, forKey: "selectedModel") } } - var selectedAgentProvider: AgentProvider = AgentProvider(rawValue: UserDefaults.standard.string(forKey: "selectedAgentProvider") ?? "") ?? .claudeCode { + var selectedAgentProvider: AgentProvider = .init(rawValue: UserDefaults.standard.string(forKey: "selectedAgentProvider") ?? "") ?? .claudeCode { didSet { UserDefaults.standard.set(selectedAgentProvider.rawValue, forKey: "selectedAgentProvider") } } var codexModels: [AgentModel] = [] + // MARK: - ACP + + /// User-installed ACP clients. Loaded from disk on init. + var acpClients: [ACPClientSpec] = [] + /// Cached registry from `cdn.agentclientprotocol.com`. Refreshed hourly. + var acpRegistry: ACPRegistry? + var acpRegistryLoading: Bool = false + + /// Selected default ACP client id (when `selectedAgentProvider == .acp`). + var selectedACPClientId: String = UserDefaults.standard.string(forKey: "selectedACPClientId") ?? "" { + didSet { UserDefaults.standard.set(selectedACPClientId, forKey: "selectedACPClientId") } + } + var selectedEffort: String = UserDefaults.standard.string(forKey: "selectedEffort") ?? "auto" { didSet { UserDefaults.standard.set(selectedEffort, forKey: "selectedEffort") } } // MARK: - Summarization - var summarizationProvider: SummarizationProvider = SummarizationProvider(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient { + var summarizationProvider: SummarizationProvider = .init(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient { didSet { UserDefaults.standard.set(summarizationProvider.rawValue, forKey: "summarizationProvider") } } @@ -396,7 +511,8 @@ final class AppState { var autoPreviewSettings: AttachmentAutoPreviewSettings = { guard let data = UserDefaults.standard.data(forKey: AppState.autoPreviewSettingsKey), - let settings = try? JSONDecoder().decode(AttachmentAutoPreviewSettings.self, from: data) else { + let settings = try? JSONDecoder().decode(AttachmentAutoPreviewSettings.self, from: data) + else { return AttachmentAutoPreviewSettings() } return settings @@ -438,6 +554,8 @@ final class AppState { return latestRateLimitUsage case .codex: return latestCodexRateLimitUsage + case .acp: + return nil } } @@ -458,6 +576,8 @@ final class AppState { case .codex: guard self.codexInstalled else { return nil } return await self.codex.fetchRateLimits(forceRefresh: forceRefresh) + case .acp: + return nil } } rateLimitUsageRefreshTasks[provider] = task @@ -478,6 +598,8 @@ final class AppState { latestRateLimitUsage = usage case .codex: latestCodexRateLimitUsage = usage + case .acp: + break } } @@ -497,6 +619,8 @@ final class AppState { await refreshRateLimitUsage(forceRefresh: forceRefresh) case .codex: await refreshCodexRateLimitUsage(forceRefresh: forceRefresh) + case .acp: + break } } @@ -508,7 +632,8 @@ final class AppState { .first(where: { $0.provider == provider })? .models .first? - .id { + .id + { selectedModel = model } } @@ -558,9 +683,14 @@ final class AppState { window.sessionAgentProvider = resolvedProvider window.sessionModel = model let key = window.currentSessionId ?? window.newSessionKey + let acpParts = resolvedProvider == .acp ? acpSelectionParts(for: model) : nil + if let acpParts { + selectedACPClientId = acpParts.clientId + } updateState(key) { state in state.agentProvider = resolvedProvider state.model = model + state.acpClientId = acpParts?.clientId // Drop the cached CLI-reported name so the status line reflects the // user's choice immediately; the next system event will refill it. state.activeModelName = nil @@ -632,7 +762,7 @@ final class AppState { if let active = activeModelName(in: window) { return active } - return Self.modelDisplayName(model, provider: provider) + return modelDisplayLabel(model, provider: provider) } static func formatModelId(_ raw: String) -> String { @@ -645,7 +775,8 @@ final class AppState { let parts = lower.components(separatedBy: CharacterSet(charactersIn: "-")) if let idx = parts.firstIndex(where: { $0 == family.lowercased() }), - idx + 1 < parts.count { + idx + 1 < parts.count + { let ver = parts[(idx + 1)...].prefix(2).filter { $0.allSatisfy(\.isNumber) } if !ver.isEmpty { return "\(family) \(ver.joined(separator: "."))" } } @@ -699,6 +830,8 @@ final class AppState { let cliStore: CLISessionStore let claude: ClaudeService let codex: CodexAppServer + let acp: ACPService + let acpRegistryService = ACPRegistryService() let openAISummarization = OpenAISummarizationService() let persistence: PersistenceService let marketplace = MarketplaceService() @@ -765,9 +898,172 @@ final class AppState { let claude = ClaudeService(cliStore: cliStore) self.claude = claude self.codex = CodexAppServer() + let acp = ACPService() + self.acp = acp self.persistence = PersistenceService(metaStore: metaStore, cliStore: cliStore) self.mcp = MCPService(claudeService: claude) self.threadStore = ThreadStore.make() + + // Bridge ACP `session/request_permission` into the existing PermissionServer. + let permission = self.permission + Task { await acp.setPermissionServer(permission) } + } + + // MARK: - ACP Actions + + func loadACPClientsFromDisk() async { + let loaded = await persistence.loadACPClients() + acpClients = loaded + } + + func saveACPClients() { + let clients = acpClients + Task { [persistence] in + try? await persistence.saveACPClients(clients) + } + } + + func refreshACPRegistry(forceRefresh: Bool = false) async { + acpRegistryLoading = true + defer { acpRegistryLoading = false } + let snapshotURL = persistence.acpRegistrySnapshotURL() + if let reg = await acpRegistryService.fetchRegistry(forceRefresh: forceRefresh, snapshotURL: snapshotURL) { + acpRegistry = reg + } + } + + func addACPClient(_ spec: ACPClientSpec) { + acpClients.append(spec) + saveACPClients() + } + + func updateACPClient(_ spec: ACPClientSpec) { + guard let idx = acpClients.firstIndex(where: { $0.id == spec.id }) else { return } + acpClients[idx] = spec + saveACPClients() + } + + func removeACPClient(id: String) { + if let removed = acpClients.first(where: { $0.id == id }) { + // Clean up the on-disk install if this client owns a binary + // under the installer's managed root. + if case .binary(let path, _, _) = removed.launch, + let registryId = removed.registryId, + ACPInstallerService.isManaged(path: path) + { + Task.detached { await ACPInstallerService.shared.uninstall(registryId: registryId) } + } + } + acpClients.removeAll { $0.id == id } + if selectedACPClientId == id { selectedACPClientId = "" } + saveACPClients() + } + + /// Installs an ACP client from a registry entry. Tries the platform's + /// declared binary distribution first (downloading and extracting it), + /// then falls back to whatever else the registry declares (`npx`/`uvx`). + /// After install, probes the agent (`initialize` + `session/new`) to + /// populate the model picker from its advertised `configOptions`. + func installACPClient(from agent: ACPRegistryAgent) async throws -> ACPClientSpec { + let launch = try await resolveLaunch(for: agent) + let spec = ACPClientSpec( + registryId: agent.id, + displayName: agent.name, + launch: launch, + iconURL: agent.icon + ) + return await probedSpec(spec, agentId: agent.id) + } + + /// Re-probes an installed client and persists the result. If the probe + /// fails or the agent doesn't expose a model selector, the picker falls + /// back to the built-in defaults for known registry agents. + func refreshACPClientModels(id: String) async { + guard let idx = acpClients.firstIndex(where: { $0.id == id }) else { return } + let current = acpClients[idx] + let updated = await probedSpec(current, agentId: current.registryId ?? current.id) + if let liveIdx = acpClients.firstIndex(where: { $0.id == id }) { + acpClients[liveIdx] = updated + saveACPClients() + } + } + + private func probedSpec(_ spec: ACPClientSpec, agentId: String) async -> ACPClientSpec { + var result = spec + let probeCwd = NSHomeDirectory() + do { + if let config = try await acp.probeModels(spec: spec, cwd: probeCwd) { + result.modelConfigId = config.configId + result.models = config.options.map { $0.value } + result.modelOptions = config.options + logger.info("[ACP] probed \(result.models.count) models from \(agentId, privacy: .public) configId=\(config.configId, privacy: .public) current=\(config.currentValue ?? "nil", privacy: .public) models=[\(Self.acpModelListDescription(config.options), privacy: .public)]") + return result + } + logger.info("[ACP] no model selector advertised by \(agentId, privacy: .public)") + } catch { + logger.warning("[ACP] model probe failed for \(agentId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + // Probe missed — leave the model list empty. The picker injects a + // synthetic "Default" entry so the client is still selectable; the + // agent uses whatever model it chooses internally. + result.modelConfigId = nil + result.models = [] + result.modelOptions = nil + return result + } + + /// Writes a freshly-discovered model list back to the matching client + /// spec. Called from the stream loop whenever `session/new` advertises + /// a model selector — keeps the picker in sync with what the agent + /// actually supports without requiring a settings round-trip. + private func applyDiscoveredACPModels(clientId: String, config: ACPModelConfig) { + guard let idx = acpClients.firstIndex(where: { $0.id == clientId }) else { return } + let newModels = config.options.map { $0.value } + var updated = acpClients[idx] + logger.info("[ACP] applying discovered models clientId=\(clientId, privacy: .public) configId=\(config.configId, privacy: .public) current=\(config.currentValue ?? "nil", privacy: .public) models=\(newModels.count) [\(Self.acpModelListDescription(config.options), privacy: .public)]") + guard updated.models != newModels || updated.modelOptions != config.options || updated.modelConfigId != config.configId else { + logger.info("[ACP] discovered models unchanged for clientId=\(clientId, privacy: .public)") + return + } + updated.models = newModels + updated.modelOptions = config.options + updated.modelConfigId = config.configId + acpClients[idx] = updated + saveACPClients() + } + + private static func acpModelListDescription(_ options: [ACPModelOption]) -> String { + options.map { option in + option.name == option.value ? option.value : "\(option.value) (\(option.name))" + }.joined(separator: ", ") + } + + private func resolveLaunch(for agent: ACPRegistryAgent) async throws -> ACPClientSpec.LaunchKind { + // Prefer the platform binary; on download/extract failure, fall through. + if let bin = agent.distribution.binary?[ACPPlatform.current] { + do { + let path = try await ACPInstallerService.shared.install( + bin, registryId: agent.id, version: agent.version + ) + return .binary(path: path, args: bin.args ?? [], env: bin.env ?? [:]) + } catch { + if let npx = agent.distribution.npx { + return .npx(package: npx.package, args: npx.args ?? [], env: npx.env ?? [:]) + } + if let uvx = agent.distribution.uvx { + return .uvx(package: uvx.package, args: uvx.args ?? [], env: uvx.env ?? [:]) + } + throw error + } + } + if let npx = agent.distribution.npx { + return .npx(package: npx.package, args: npx.args ?? [], env: npx.env ?? [:]) + } + if let uvx = agent.distribution.uvx { + return .uvx(package: uvx.package, args: uvx.args ?? [], env: uvx.env ?? [:]) + } + throw ACPInstallError.noCompatibleDistribution } // MARK: - MCP Actions @@ -855,7 +1151,8 @@ final class AppState { // Disconnect notification: only fire on the connected→failed edge so we // don't spam the user on every failed re-probe. if case .connected = (previousStatus ?? .unknown), - case .failed(let message) = newStatus { + case .failed(let message) = newStatus + { let notifyService = NotificationService.shared let serverName = name Task { @MainActor in @@ -1050,7 +1347,8 @@ final class AppState { func todoProgress(forSessionId id: String) -> ChatTodoProgress? { if let messages = sessionStates[id]?.messages, - let todos = TodoExtractor.latest(in: messages) { + let todos = TodoExtractor.latest(in: messages) + { return ChatTodoProgress(todos: todos) } @@ -1098,11 +1396,16 @@ final class AppState { persistedQueues = threadStore.loadAllQueues() - if (claudeInstalled || codexInstalled) && !onboardingCompleted { + if claudeInstalled || codexInstalled, !onboardingCompleted { onboardingCompleted = true UserDefaults.standard.set(true, forKey: "onboardingCompleted") } + // Hydrate ACP state (clients + cached registry) early so the model picker + // and Settings tab don't flash empty on first open. + await loadACPClientsFromDisk() + Task { await self.refreshACPRegistry(forceRefresh: false) } + permissionMode = Self.readPermissionModeFromSettings() do { @@ -1214,7 +1517,8 @@ final class AppState { if toolName == "AskUserQuestion", window.presentedPermissionId == nil, let qSession = request.sessionId, - qSession == window.currentSessionId { + qSession == window.currentSessionId + { window.presentedPermissionId = request.id } Task { @MainActor in @@ -1262,11 +1566,13 @@ final class AppState { } if let projectId = selectingProjectId, - let project = projects.first(where: { $0.id == projectId }) { + let project = projects.first(where: { $0.id == projectId }) + { selectProject(project, in: window) } else if let savedId = UserDefaults.standard.string(forKey: "selectedProjectId"), let uuid = UUID(uuidString: savedId), - let project = projects.first(where: { $0.id == uuid }) { + let project = projects.first(where: { $0.id == uuid }) + { selectProject(project, in: window) } else if let first = projects.first { selectProject(first, in: window) @@ -1421,7 +1727,8 @@ final class AppState { if (window.sessionAgentProvider ?? selectedAgentProvider) == .claudeCode, let sid = window.currentSessionId, let cwd = window.selectedProject?.path, - cliStore.detectExternalActivity(sid: sid, cwd: cwd, withinSeconds: 5) { + cliStore.detectExternalActivity(sid: sid, cwd: cwd, withinSeconds: 5) + { logger.warning("Session \(sid, privacy: .public) jsonl was modified within 5s — another claude process may be active") } @@ -1505,7 +1812,7 @@ final class AppState { } func openTerminal(in window: WindowState) async { - if window.showInspector && window.inspectorTab == .terminal { + if window.showInspector, window.inspectorTab == .terminal { window.showInspector = false } else { window.inspectorTab = .terminal @@ -1649,7 +1956,7 @@ final class AppState { messages: [], agentProvider: provider, model: window.sessionModel ?? selectedModel, - origin: provider == .codex ? .codexAppServer : .cliBacked + origin: provider.defaultSessionOrigin ) allSessionSummaries.insert(placeholder.summary, at: 0) threadStore.upsert(placeholder.summary) @@ -1660,7 +1967,8 @@ final class AppState { // without waiting for the assistant to reply. if wasFirstUserMessage, !skipAppendingUserMessage, - !(sessionStates[sessionKey]?.titleGenerationTriggered ?? false) { + !(sessionStates[sessionKey]?.titleGenerationTriggered ?? false) + { updateState(sessionKey) { $0.titleGenerationTriggered = true } let titleKey = sessionKey Task { [weak self] in @@ -1692,7 +2000,7 @@ final class AppState { ?? window.sessionAgentProvider ?? selectedAgentProvider var hookSettingsPath: String? - if launchAgentProvider == .claudeCode && !cliPermissionMode.skipsHookPipeline { + if launchAgentProvider == .claudeCode, !cliPermissionMode.skipsHookPipeline { do { hookSettingsPath = try await permission.writeHookSettingsFile() } catch { @@ -1861,6 +2169,42 @@ final class AppState { mcpConfigOverrides: mcpConfigOverrides, permissionServer: permission ) + case .acp: + // `model` may be a composite `::` key (from the picker) + // or a bare model id (from a per-session override). + let split = acpSelectionParts(for: model) + let resolvedClientId = split?.clientId + ?? sessionStates[sessionKey]?.acpClientId + ?? selectedACPClientId + let resolvedModel = split?.model ?? model + guard let spec = acpClients.first(where: { $0.id == resolvedClientId && $0.enabled }) else { + logger.error("[ACP] no enabled client for id=\(resolvedClientId, privacy: .public)") + let placeholder = AsyncStream { c in + c.yield(.user(UserMessage( + toolUseId: nil, + content: "No ACP client configured. Add one in Settings → ACP Clients.", + isError: true + ))) + c.yield(.result(ResultEvent( + durationMs: nil, totalCostUsd: nil, + sessionId: cliSessionId ?? sessionKey, + isError: true, totalTurns: nil, usage: nil, contextWindow: nil + ))) + c.finish() + } + stream = placeholder + break + } + stream = await acp.send( + streamId: streamId, + prompt: prompt, + cwd: cwd, + sessionId: cliSessionId, + model: resolvedModel, + spec: spec, + permissionMode: registerMode, + clientSessionKey: sessionKey + ) } startFlushTimer(for: sessionKey) @@ -1881,7 +2225,7 @@ final class AppState { let ownsSession = stateForSession(sessionKey).activeStreamId == streamId - if !ownsSession { + if !ownsSession { if case .result(let resultEvent) = event { logger.info("[Stream:UI] event #\(eventCount) .result received after losing ownership — saving to disk") await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) @@ -1937,7 +2281,8 @@ final class AppState { let expectedPlaceholder = "pending-\(streamId.uuidString)" if window.pendingPlaceholderIds.contains(expectedPlaceholder), - let idx = allSessionSummaries.firstIndex(where: { $0.id == expectedPlaceholder }) { + let idx = allSessionSummaries.firstIndex(where: { $0.id == expectedPlaceholder }) + { let old = allSessionSummaries[idx] // Preserve the placeholder's original timestamp so an empty // session (no assistant content yet) doesn't leapfrog @@ -1950,11 +2295,16 @@ final class AppState { messages: [], createdAt: old.createdAt, updatedAt: old.createdAt, + isPinned: old.isPinned, agentProvider: old.agentProvider, model: old.model, effort: old.effort, permissionMode: old.permissionMode, - origin: old.origin + origin: old.origin, + worktreePath: old.worktreePath, + worktreeBranch: old.worktreeBranch, + isArchived: old.isArchived, + archivedAt: old.archivedAt ) allSessionSummaries.removeAll { $0.id == expectedPlaceholder || $0.id == sid } allSessionSummaries.insert(replacement.summary, at: 0) @@ -1972,7 +2322,7 @@ final class AppState { // so expectedPlaceholder won't match oldKey. Clean up the stale placeholder // here to prevent the old entry from persisting as a duplicate in history. let oldKey = sessionKey == sid ? internalSessionKey : sessionKey - if oldKey != expectedPlaceholder && window.pendingPlaceholderIds.contains(oldKey) { + if oldKey != expectedPlaceholder, window.pendingPlaceholderIds.contains(oldKey) { allSessionSummaries.removeAll { $0.id == oldKey } threadStore.delete(id: oldKey) window.removePendingPlaceholder(oldKey) @@ -2011,7 +2361,9 @@ final class AppState { permissionMode: old.permissionMode, origin: old.origin, worktreePath: old.worktreePath, - worktreeBranch: old.worktreeBranch + worktreeBranch: old.worktreeBranch, + isArchived: old.isArchived, + archivedAt: old.archivedAt ) allSessionSummaries.remove(at: idx) allSessionSummaries.removeAll { $0.id == to } @@ -2031,7 +2383,7 @@ final class AppState { updatedAt: firstUserDate, isPinned: false, agentProvider: agentProvider, - origin: .cliBacked + origin: agentProvider.defaultSessionOrigin ) allSessionSummaries.insert(inserted, at: 0) threadStore.upsert(inserted, cliSessionId: id) @@ -2123,7 +2475,7 @@ final class AppState { } let isFg = (window.currentSessionId ?? window.newSessionKey) == sessionKey - if !isFg && !resultEvent.isError { + if !isFg, !resultEvent.isError { updateState(sessionKey) { $0.hasUncheckedCompletion = true } } if isFg { @@ -2158,7 +2510,7 @@ final class AppState { } } - if notificationsEnabled && !NSApp.isActive { + if notificationsEnabled, !NSApp.isActive { let title = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId })?.title ?? "New Session" let firstSentence = stateForSession(sessionKey).messages .last(where: { $0.role == .assistant && !$0.isError }) @@ -2185,7 +2537,8 @@ final class AppState { case .rateLimitEvent(let info): logger.warning("[Stream:UI] event #\(eventCount) .rateLimitEvent (retrySec=\(info.retrySec ?? 0))") if (window.currentSessionId ?? window.newSessionKey) == sessionKey, - let retry = info.retrySec, retry > 0 { + let retry = info.retrySec, retry > 0 + { addErrorMessage("Rate limited. Retrying in \(Int(retry))s...", in: window) } @@ -2198,6 +2551,10 @@ final class AppState { ) threadStore.upsertTodoSnapshot(sessionId: targetSession, items: snapshot.items) + case .acpModelsDiscovered(let event): + logger.info("[Stream:UI] event #\(eventCount) .acpModelsDiscovered clientId=\(event.clientId, privacy: .public) configId=\(event.config.configId, privacy: .public) models=\(event.config.options.count) [\(Self.acpModelListDescription(event.config.options), privacy: .public)]") + applyDiscoveredACPModels(clientId: event.clientId, config: event.config) + case .unknown(let raw): if eventCount <= 5 || eventCount % 100 == 0 { logger.debug("[Stream:UI] event #\(eventCount) .unknown (gap=\(String(format: "%.1f", gap))s, len=\(raw.count))") @@ -2228,7 +2585,7 @@ final class AppState { let isStillOwner = stateForSession(sessionKey).activeStreamId == streamId let stillStreaming = stateForSession(sessionKey).isStreaming - if stillStreaming && isStillOwner { + if stillStreaming, isStillOwner { logger.warning("[Stream:UI] isStreaming was still true at stream end — forcing cleanup") finalizeStreamSession(for: sessionKey) if (window.currentSessionId ?? window.newSessionKey) != sessionKey { @@ -2250,7 +2607,7 @@ final class AppState { if !msgs.isEmpty { await saveSession(sessionId: sessionKey, projectId: projectId, messages: msgs) } - } else if stillStreaming && !isStillOwner { + } else if stillStreaming, !isStillOwner { let currentOwner = stateForSession(sessionKey).activeStreamId if currentOwner == nil { logger.warning("[Stream:UI] stream \(streamId) ended — no active owner for session, forcing cleanup") @@ -2273,6 +2630,8 @@ final class AppState { await claude.finalize(streamId: streamId) case .codex: await codex.finalize(streamId: streamId) + case .acp: + await acp.finalize(streamId: streamId) } } @@ -2282,6 +2641,8 @@ final class AppState { return await claude.consumeStderr(for: streamId) case .codex: return await codex.consumeStderr(for: streamId) + case .acp: + return await acp.consumeStderr(for: streamId) } } @@ -2402,7 +2763,8 @@ final class AppState { let event: [String: Any] if let type = json["type"] as? String, type == "stream_event", - let nested = json["event"] as? [String: Any] { + let nested = json["event"] as? [String: Any] + { event = nested } else { event = json @@ -2436,7 +2798,8 @@ final class AppState { state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) } if let lastIndex = state.messages.indices.last, - state.messages[lastIndex].role == .assistant { + state.messages[lastIndex].role == .assistant + { state.messages[lastIndex].appendToolCall(toolCall) } // Ready to receive input_json_delta @@ -2490,10 +2853,12 @@ final class AppState { let parsed = try? JSONDecoder().decode([String: JSONValue].self, from: inputData) else { return } if let msgIdx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }), - let blockIdx = state.messages[msgIdx].toolCallIndex(id: toolId) { + let blockIdx = state.messages[msgIdx].toolCallIndex(id: toolId) + { state.messages[msgIdx].blocks[blockIdx].toolCall?.input = parsed if let toolName = state.messages[msgIdx].blocks[blockIdx].toolCall?.name, - toolName.lowercased() == "todowrite" { + toolName.lowercased() == "todowrite" + { let todos = TodoExtractor.parse(input: parsed) let done = todos.filter { $0.status == .completed }.count let active = todos.first(where: { $0.status == .inProgress })?.activeForm ?? "-" @@ -2536,6 +2901,8 @@ final class AppState { await claude.cancel(streamId: streamToCancel) case .codex: await codex.cancel(streamId: streamToCancel) + case .acp: + await acp.cancel(streamId: streamToCancel) } } @@ -2557,7 +2924,8 @@ final class AppState { if let lastIndex = state.messages.indices.last, state.messages[lastIndex].role == .assistant, !state.messages[lastIndex].isError, - !state.messages[lastIndex].isCompactBoundary { + !state.messages[lastIndex].isCompactBoundary + { state.messages.remove(at: lastIndex) } // Restore the user message that triggered this stream into the input field — @@ -2565,7 +2933,8 @@ final class AppState { // aren't silently dropped when the user stops a turn. if let lastIndex = state.messages.indices.last, state.messages[lastIndex].role == .user, - !state.messages[lastIndex].isCompactBoundary { + !state.messages[lastIndex].isCompactBoundary + { let userText = state.messages[lastIndex].blocks.compactMap(\.text).joined() state.messages.remove(at: lastIndex) window.inputText = userText @@ -2764,19 +3133,35 @@ final class AppState { } } - /// True when the assistant produced any content (text or tool calls) after the - /// given ExitPlanMode tool call in the current session — either as later blocks - /// in the same message or as subsequent messages. Used to suppress the hidden - /// "Proceed with the plan." continuation prompt when the CLI has already carried - /// the turn through to completion on its own. + /// True when the assistant actually *executed* the plan after the given + /// ExitPlanMode tool call — i.e. the CLI invoked at least one other tool + /// (Edit / Write / Bash / etc.) in the same turn, so a follow-up + /// "Proceed with the plan." would just spawn a redundant turn. Used to + /// gate the hidden continuation prompt. + /// + /// Text-only post-plan content (a brief preamble, or a recap of the plan + /// the model already wrote into a file) does NOT count — that's the stall + /// mode where the user is left waiting and we *do* want to inject the + /// nudge so implementation actually starts. private func turnContinuedAfterPlan(toolUseId: String, sessionKey: String) -> Bool { guard let messages = sessionStates[sessionKey]?.messages else { return false } for messageIdx in messages.indices.reversed() { - guard let blockIdx = messages[messageIdx].toolCallIndex(id: toolUseId) else { + guard let planBlockIdx = messages[messageIdx].toolCallIndex(id: toolUseId) else { continue } - if blockIdx < messages[messageIdx].blocks.count - 1 { return true } - if messageIdx < messages.count - 1 { return true } + // Same message: any tool-call block after the plan block counts as work. + let trailingBlocks = messages[messageIdx].blocks.dropFirst(planBlockIdx + 1) + if trailingBlocks.contains(where: { $0.toolCall != nil }) { + return true + } + // Subsequent assistant messages: any tool-call block at all. + if messageIdx + 1 < messages.count { + for later in messages[(messageIdx + 1)...] where later.role == .assistant { + if later.blocks.contains(where: { $0.toolCall != nil }) { + return true + } + } + } return false } return false @@ -2833,21 +3218,28 @@ final class AppState { if let currentId = window.currentSessionId, let currentProject = window.selectedProject, let state = sessionStates[currentId], - !state.messages.isEmpty { + !state.messages.isEmpty + { let title = allSessionSummaries.first(where: { $0.id == currentId })?.title ?? "Session" let provider = state.agentProvider ?? allSessionSummaries.first(where: { $0.id == currentId })?.agentProvider ?? selectedAgentProvider - let origin = allSessionSummaries.first(where: { $0.id == currentId })?.origin ?? (provider == .codex ? .codexAppServer : .cliBacked) + let origin = allSessionSummaries.first(where: { $0.id == currentId })?.origin ?? provider.defaultSessionOrigin + let summary = allSessionSummaries.first(where: { $0.id == currentId }) let session = ChatSession( id: currentId, projectId: currentProject.id, title: title, messages: state.messages, updatedAt: lastResponseDate(from: state.messages), + isPinned: summary?.isPinned ?? false, agentProvider: provider, model: state.model, effort: state.effort, permissionMode: state.permissionMode, - origin: origin + origin: origin, + worktreePath: summary?.worktreePath, + worktreeBranch: summary?.worktreeBranch, + isArchived: summary?.isArchived ?? false, + archivedAt: summary?.archivedAt ) Task { do { try await self.persistence.saveSession(session) } @@ -2950,7 +3342,8 @@ final class AppState { } } else if sessionStates[session.id]?.messages.isEmpty == true, sessionStates[session.id]?.isStreaming != true, - let project = window.selectedProject { + let project = window.selectedProject + { if var state = sessionStates[session.id] { if state.model == nil { state.model = session.model } if state.agentProvider == nil { state.agentProvider = session.agentProvider } @@ -2995,21 +3388,27 @@ final class AppState { Task { [weak self] in guard let self else { return } if !outgoingMessages.isEmpty, let project = window.selectedProject { - let title = allSessionSummaries.first(where: { $0.id == outgoingId })?.title ?? "Session" + let summary = allSessionSummaries.first(where: { $0.id == outgoingId }) + let title = summary?.title ?? "Session" let state = sessionStates[outgoingId] - let provider = state?.agentProvider ?? allSessionSummaries.first(where: { $0.id == outgoingId })?.agentProvider ?? selectedAgentProvider - let origin = allSessionSummaries.first(where: { $0.id == outgoingId })?.origin ?? (provider == .codex ? .codexAppServer : .cliBacked) + let provider = state?.agentProvider ?? summary?.agentProvider ?? selectedAgentProvider + let origin = summary?.origin ?? provider.defaultSessionOrigin let outgoing = ChatSession( id: outgoingId, projectId: project.id, title: title, messages: outgoingMessages, updatedAt: lastResponseDate(from: outgoingMessages), + isPinned: summary?.isPinned ?? false, agentProvider: provider, model: state?.model, effort: state?.effort, permissionMode: state?.permissionMode, - origin: origin + origin: origin, + worktreePath: summary?.worktreePath, + worktreeBranch: summary?.worktreeBranch, + isArchived: summary?.isArchived ?? false, + archivedAt: summary?.archivedAt ) do { try await persistence.saveSession(outgoing) } catch { logger.error("Failed to save outgoing session: \(error.localizedDescription)") } @@ -3164,7 +3563,16 @@ final class AppState { projectId: project.id, title: title, messages: sessionStates[currentId]?.messages ?? messages, - origin: stillPlaceholder.origin + isPinned: stillPlaceholder.isPinned, + agentProvider: stillPlaceholder.agentProvider, + model: stillPlaceholder.model, + effort: stillPlaceholder.effort, + permissionMode: stillPlaceholder.permissionMode, + origin: stillPlaceholder.origin, + worktreePath: stillPlaceholder.worktreePath, + worktreeBranch: stillPlaceholder.worktreeBranch, + isArchived: stillPlaceholder.isArchived, + archivedAt: stillPlaceholder.archivedAt ) await renameSession(session, to: title) } @@ -3192,6 +3600,9 @@ final class AppState { return await claude.generateSessionTitle(firstUserMessage: firstUserMessage, model: model ?? "haiku") case .codex: return await codex.generateSessionTitle(firstUserMessage: firstUserMessage, model: model) + case .acp: + // No standardized title-generation in ACP; fall back to the truncation logic upstream. + return nil } } @@ -3279,6 +3690,7 @@ final class AppState { /// `persistTitle` should be true only for explicit user renames; pin and /// other non-title edits leave the sidecar title untouched so it stays /// in sync with the CLI's first-message-derived label. + // MARK: - Worktree /// Create a Git worktree for the chat and remember it on the session. @@ -3360,7 +3772,7 @@ final class AppState { var updated: ChatSession = switch summary.origin { case .cliBacked: summary.makeSession() - case .legacyRxCode, .codexAppServer: + case .legacyRxCode, .codexAppServer, .acpAgent: persistence.loadLegacySessionSync(projectId: session.projectId, sessionId: session.id) ?? session } mutate(&updated) @@ -3485,11 +3897,15 @@ final class AppState { allSessionSummaries.removeAll { ids.contains($0.id) } if archivedOnly { - for id in ids { threadStore.delete(id: id) } + for id in ids { + threadStore.delete(id: id) + } } else { threadStore.deleteAll(projectId: projectId) } - for id in ids { sessionStates.removeValue(forKey: id) } + for id in ids { + sessionStates.removeValue(forKey: id) + } } func selectSession(id: String, in window: WindowState) { @@ -3502,7 +3918,8 @@ final class AppState { window.cancelSessionSwitchTask() if let summary = allSessionSummaries.first(where: { $0.id == id }), - summary.projectId == window.selectedProject?.id { + summary.projectId == window.selectedProject?.id + { logger.info("[SelectSession] match in current project sid=\(id, privacy: .public) origin=\(String(describing: summary.origin), privacy: .public) title=\(summary.title, privacy: .public)") let session = summary.makeSession() switchToSession(session, in: window) @@ -3516,7 +3933,8 @@ final class AppState { // If it's a session from another project, switch the project as well guard let summary = allSessionSummaries.first(where: { $0.id == id }), - let project = projects.first(where: { $0.id == summary.projectId }) else { + let project = projects.first(where: { $0.id == summary.projectId }) + else { logger.error("[SelectSession] summary or project missing for sid=\(id, privacy: .public)") return } @@ -3530,7 +3948,8 @@ final class AppState { if let s = allSessionSummaries.first(where: { $0.id == id }) { let session = s.makeSession() if sessionStates[session.id] == nil, - let full = await persistence.loadFullSession(summary: s, cwd: project.path) { + let full = await persistence.loadFullSession(summary: s, cwd: project.path) + { logger.info("[SelectSession] cross-project preload ok sid=\(id, privacy: .public) messages=\(full.messages.count)") switchToSession(full, messages: full.messages, in: window) } else { @@ -3617,7 +4036,7 @@ final class AppState { raw.compactMap { message in var msg = message msg.isStreaming = false - if msg.blocks.isEmpty && msg.role == .assistant { return nil } + if msg.blocks.isEmpty, msg.role == .assistant { return nil } return msg } } @@ -3636,7 +4055,7 @@ final class AppState { id: sessionId, projectId: projectId, title: "", createdAt: Date(), updatedAt: Date(), isPinned: false, agentProvider: sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider, - origin: (sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider) == .codex ? .codexAppServer : .cliBacked + origin: (sessionStates[sessionId]?.agentProvider ?? selectedAgentProvider).defaultSessionOrigin ) } @@ -3740,33 +4159,41 @@ final class AppState { // Preserve the existing title (which may have been renamed by the user or // generated by the LLM). Fall back to the default placeholder; the LLM // title generator replaces it once the first assistant reply arrives. + let summary = allSessionSummaries.first(where: { $0.id == sessionId }) let title: String - if let existing = allSessionSummaries.first(where: { $0.id == sessionId }), !existing.title.isEmpty { + if let existing = summary, !existing.title.isEmpty { title = existing.title } else { title = ChatSession.defaultTitle } let sessionModel = sessionStates[sessionId]?.model - ?? allSessionSummaries.first(where: { $0.id == sessionId })?.model + ?? summary?.model let sessionAgentProvider = sessionStates[sessionId]?.agentProvider - ?? allSessionSummaries.first(where: { $0.id == sessionId })?.agentProvider + ?? summary?.agentProvider ?? selectedAgentProvider let sessionEffort = sessionStates[sessionId]?.effort + ?? summary?.effort let sessionPermissionMode = sessionStates[sessionId]?.permissionMode - let origin = allSessionSummaries.first(where: { $0.id == sessionId })?.origin - ?? (sessionAgentProvider == .codex ? .codexAppServer : .cliBacked) + ?? summary?.permissionMode + let origin = summary?.origin + ?? sessionAgentProvider.defaultSessionOrigin let session = ChatSession( id: sessionId, projectId: projectId, title: title, messages: messages, updatedAt: lastResponseDate(from: messages), + isPinned: summary?.isPinned ?? false, agentProvider: sessionAgentProvider, model: sessionModel, effort: sessionEffort, permissionMode: sessionPermissionMode, - origin: origin + origin: origin, + worktreePath: summary?.worktreePath, + worktreeBranch: summary?.worktreeBranch, + isArchived: summary?.isArchived ?? false, + archivedAt: summary?.archivedAt ) do { @@ -3781,7 +4208,8 @@ final class AppState { let summary = session.summary withAnimation(nil) { while allSessionSummaries.filter({ $0.id == sessionId }).count > 1, - let lastIdx = allSessionSummaries.lastIndex(where: { $0.id == sessionId }) { + let lastIdx = allSessionSummaries.lastIndex(where: { $0.id == sessionId }) + { allSessionSummaries.remove(at: lastIdx) } if let index = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { @@ -3963,7 +4391,7 @@ final class AppState { let currentPermissionMode = sessionStates[sessionKey]?.permissionMode ?? permissionMode let agentProvider = sessionStates[sessionKey]?.agentProvider ?? selectedAgentProvider var hookSettingsPath: String? - if agentProvider == .claudeCode && !currentPermissionMode.skipsHookPipeline { + if agentProvider == .claudeCode, !currentPermissionMode.skipsHookPipeline { do { hookSettingsPath = try await permission.writeHookSettingsFile() } catch { logger.error("Failed to write hook settings for background queue: \(error.localizedDescription)") } } @@ -3988,7 +4416,9 @@ final class AppState { projectId: projectId, window: window ) - for path in tempFilePaths { try? FileManager.default.removeItem(atPath: path) } + for path in tempFilePaths { + try? FileManager.default.removeItem(atPath: path) + } } sessionStates[sessionKey]?.streamTask = task } @@ -4013,11 +4443,13 @@ final class AppState { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let permissions = json["permissions"] as? [String: Any], let mode = permissions["defaultMode"] as? String, - let parsed = PermissionMode(rawValue: mode) { + let parsed = PermissionMode(rawValue: mode) + { return parsed } if let saved = UserDefaults.standard.string(forKey: "selectedPermissionMode"), - let parsed = PermissionMode(rawValue: saved) { + let parsed = PermissionMode(rawValue: saved) + { return parsed } return .default diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index ce7093f3..bebad15e 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -135,6 +135,7 @@ private struct MenuBarLabel: View { switch provider { case .claudeCode: return "CC" case .codex: return "CODEX" + case .acp: return "ACP" } } @@ -203,6 +204,7 @@ private struct MenuBarContentView: View { switch appState.selectedAgentProvider { case .claudeCode: return appState.latestRateLimitUsage case .codex: return appState.latestCodexRateLimitUsage + case .acp: return nil } } @@ -210,6 +212,7 @@ private struct MenuBarContentView: View { switch appState.selectedAgentProvider { case .claudeCode: return "7-day limit" case .codex: return "24-hour limit" + case .acp: return "Usage" } } @@ -217,6 +220,7 @@ private struct MenuBarContentView: View { switch appState.selectedAgentProvider { case .claudeCode: return selectedUsage?.sevenDayPercent case .codex: return selectedUsage?.twentyFourHourPercent + case .acp: return nil } } @@ -224,6 +228,7 @@ private struct MenuBarContentView: View { switch appState.selectedAgentProvider { case .claudeCode: return selectedUsage?.sevenDayResetsAt case .codex: return selectedUsage?.twentyFourHourResetsAt + case .acp: return nil } } @@ -312,6 +317,7 @@ private struct MenuBarContentView: View { switch appState.selectedAgentProvider { case .claudeCode: return "Sign in to Claude Code to see usage" case .codex: return "Sign in to Codex to see usage" + case .acp: return "Usage tracking not supported by ACP" } } diff --git a/RxCode/Services/ACPInstallerService.swift b/RxCode/Services/ACPInstallerService.swift new file mode 100644 index 00000000..ee39d694 --- /dev/null +++ b/RxCode/Services/ACPInstallerService.swift @@ -0,0 +1,169 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - ACPInstallerService +// +// Downloads and extracts an ACP agent's binary distribution into +// `~/Library/Application Support/RxCode/acp-binaries///`, +// then returns the absolute path of the executable named by `bin.cmd`. +// +// Supports `.zip`, `.tar.gz`, and `.tgz` archives. The macOS quarantine +// xattr is stripped after extraction so Gatekeeper doesn't block first +// launch. + +enum ACPInstallError: LocalizedError { + case unsupportedArchive(String) + case downloadFailed(underlying: Error) + case extractFailed(stderr: String, exitCode: Int32) + case cmdMissing(path: String) + case noCompatibleDistribution + + var errorDescription: String? { + switch self { + case .unsupportedArchive(let url): + return "Unsupported archive format: \(url)" + case .downloadFailed(let underlying): + return "Download failed: \(underlying.localizedDescription)" + case .extractFailed(let stderr, let exitCode): + return "Extraction failed (exit \(exitCode)): \(stderr)" + case .cmdMissing(let path): + return "Executable not found at \(path) after extraction" + case .noCompatibleDistribution: + return "No compatible distribution for this platform." + } + } +} + +actor ACPInstallerService { + + private let logger = Logger(subsystem: "com.claudework", category: "ACPInstaller") + private let session: URLSession = .shared + + static let shared = ACPInstallerService() + + /// Returns the install root: `~/Library/Application Support/RxCode/acp-binaries/`. + static func installRoot() -> URL { + let fm = FileManager.default + let support = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return support.appendingPathComponent("RxCode/acp-binaries", isDirectory: true) + } + + /// Downloads + extracts `bin`, returning the absolute path of the resolved executable. + func install(_ bin: ACPBinaryDist, registryId: String, version: String) async throws -> String { + let installDir = Self.installRoot() + .appendingPathComponent(registryId, isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + let fm = FileManager.default + + // Resolve cmd to an absolute path early so we can short-circuit if already installed. + let resolvedCmdPath = resolveCmdPath(cmd: bin.cmd, installDir: installDir) + if fm.isExecutableFile(atPath: resolvedCmdPath) { + logger.info("[ACPInstaller] reusing existing install at \(resolvedCmdPath, privacy: .public)") + return resolvedCmdPath + } + + // Fresh install: clear stale partials, recreate dir. + try? fm.removeItem(at: installDir) + try fm.createDirectory(at: installDir, withIntermediateDirectories: true) + + guard let archiveURL = URL(string: bin.archive) else { + throw ACPInstallError.unsupportedArchive(bin.archive) + } + + logger.info("[ACPInstaller] downloading \(bin.archive, privacy: .public)") + let downloaded: URL + do { + let (tempURL, _) = try await session.download(from: archiveURL) + downloaded = tempURL + } catch { + throw ACPInstallError.downloadFailed(underlying: error) + } + defer { try? fm.removeItem(at: downloaded) } + + try extract(archive: downloaded, originalURL: archiveURL, into: installDir) + + // chmod +x and strip quarantine. + if fm.isExecutableFile(atPath: resolvedCmdPath) == false { + // File may exist without exec bit — try to chmod. + if fm.fileExists(atPath: resolvedCmdPath) { + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: resolvedCmdPath) + } else { + throw ACPInstallError.cmdMissing(path: resolvedCmdPath) + } + } else { + try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: resolvedCmdPath) + } + stripQuarantine(at: installDir) + + return resolvedCmdPath + } + + /// Removes the install directory tree for the given registry id. + /// No-op if the directory does not exist. + func uninstall(registryId: String) { + let dir = Self.installRoot().appendingPathComponent(registryId, isDirectory: true) + try? FileManager.default.removeItem(at: dir) + } + + /// Returns true if `path` is under the install root (used to decide + /// whether to clean up on remove). + nonisolated static func isManaged(path: String) -> Bool { + let root = installRoot().standardizedFileURL.path + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + return normalized.hasPrefix(root + "/") + } + + // MARK: - Helpers + + private func resolveCmdPath(cmd: String, installDir: URL) -> String { + // Strip a leading "./" if present so we can append cleanly. + var rel = cmd + if rel.hasPrefix("./") { rel.removeFirst(2) } + return installDir.appendingPathComponent(rel).path + } + + private func extract(archive: URL, originalURL: URL, into dir: URL) throws { + let lower = originalURL.lastPathComponent.lowercased() + let process = Process() + let stderrPipe = Pipe() + process.standardError = stderrPipe + process.standardOutput = Pipe() + + if lower.hasSuffix(".zip") { + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-q", "-o", archive.path, "-d", dir.path] + } else if lower.hasSuffix(".tar.gz") || lower.hasSuffix(".tgz") { + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["-xzf", archive.path, "-C", dir.path] + } else if lower.hasSuffix(".tar") { + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["-xf", archive.path, "-C", dir.path] + } else { + throw ACPInstallError.unsupportedArchive(originalURL.absoluteString) + } + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let data = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: data, encoding: .utf8) ?? "" + throw ACPInstallError.extractFailed(stderr: stderr, exitCode: process.terminationStatus) + } + } + + private func stripQuarantine(at dir: URL) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + process.arguments = ["-dr", "com.apple.quarantine", dir.path] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + } catch { + logger.warning("[ACPInstaller] failed to strip quarantine: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/RxCode/Services/ACPRegistryService.swift b/RxCode/Services/ACPRegistryService.swift new file mode 100644 index 00000000..51e5fba8 --- /dev/null +++ b/RxCode/Services/ACPRegistryService.swift @@ -0,0 +1,81 @@ +import Foundation +import RxCodeCore +import os + +/// Fetches the official Agent Client Protocol registry and caches it locally. +/// Mirrors the pattern in `MarketplaceService`. +actor ACPRegistryService { + + private let logger = Logger(subsystem: "com.claudework", category: "ACPRegistryService") + + static let registryURL = URL(string: "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json")! + + private var cached: ACPRegistry? + private var cacheDate: Date? + /// 1 hour — registry rarely changes but stale entries are harmless. + private let cacheTTL: TimeInterval = 3600 + + init() {} + + /// Returns the cached registry if fresh, otherwise fetches. + /// Returns nil only if both the network fetch and the on-disk snapshot fail. + func fetchRegistry(forceRefresh: Bool = false, snapshotURL: URL? = nil) async -> ACPRegistry? { + if !forceRefresh, let cached, let cacheDate, + Date().timeIntervalSince(cacheDate) < cacheTTL { + return cached + } + + if let registry = await downloadRegistry() { + cached = registry + cacheDate = Date() + if let snapshotURL { + writeSnapshot(registry, to: snapshotURL) + } + logger.info("Fetched ACP registry: \(registry.agents.count) agents") + return registry + } + + // Network failure — fall back to disk snapshot. + if let snapshotURL, let snapshot = readSnapshot(from: snapshotURL) { + cached = snapshot + cacheDate = Date() + logger.warning("ACP registry fetch failed; using on-disk snapshot") + return snapshot + } + + return cached + } + + private func downloadRegistry() async -> ACPRegistry? { + do { + let (data, response) = try await URLSession.shared.data(from: Self.registryURL) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + logger.warning("ACP registry HTTP failure: \((response as? HTTPURLResponse)?.statusCode ?? -1)") + return nil + } + let decoder = JSONDecoder() + return try decoder.decode(ACPRegistry.self, from: data) + } catch { + logger.warning("ACP registry decode/fetch failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + // MARK: - Snapshot Persistence + + private func writeSnapshot(_ registry: ACPRegistry, to url: URL) { + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try JSONEncoder().encode(registry) + try data.write(to: url, options: .atomic) + } catch { + logger.warning("ACP snapshot write failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func readSnapshot(from url: URL) -> ACPRegistry? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(ACPRegistry.self, from: data) + } +} diff --git a/RxCode/Services/ACPService.swift b/RxCode/Services/ACPService.swift new file mode 100644 index 00000000..8b712a05 --- /dev/null +++ b/RxCode/Services/ACPService.swift @@ -0,0 +1,1186 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - ACPService +// +// Speaks the Agent Client Protocol (https://agentclientprotocol.com) over +// newline-delimited JSON-RPC on a child process's stdio. +// +// One ACPService instance hosts many concurrent streams. Each stream owns a +// dedicated agent subprocess for its turn — the simplest mapping that mirrors +// `ClaudeService`'s one-process-per-prompt model. A future optimization could +// pool processes per (cwd, clientSpec) since ACP supports `session/load`. + +actor ACPService { + + private let logger = Logger(subsystem: "com.claudework", category: "ACPService") + + private struct StreamEntry { + let process: Process + let stdin: FileHandle + var nextId: Int = 1 + var pending: [Int: CheckedContinuation] = [:] + var agentSessionId: String? + var continuation: AsyncStream.Continuation? + var spec: ACPClientSpec + var cwd: String + var clientSessionKey: String + var stderr: String = "" + /// `tool_call.toolCallId` → synthesized RxCode ToolCall id (same string). + var liveToolCalls: Set = [] + /// Plan card uses a stable synthetic id per stream so successive `plan` updates replace the same card. + let planSyntheticId: String = "acp-plan-\(UUID().uuidString)" + var planEmitted: Bool = false + /// `SessionConfigId` of the model selector advertised by the agent's + /// `session/new` response, if any. Cached so the turn can issue + /// `session/set_config_option` to switch models. + var modelConfigId: String? + /// Toggled on just before `session/prompt` is sent. Agents like + /// OpenCode replay the historical conversation as `session/update` + /// notifications during `session/load`; we discard those so they don't + /// get rendered as live messages. + var acceptingUpdates: Bool = false + } + + private var streams: [UUID: StreamEntry] = [:] + /// Reference to the permission server for bridging `session/request_permission`. + private weak var permissionServer: PermissionServer? + + /// Cached PATH read from the user's interactive login shell, so spawned + /// `npx`/`uvx`/binary agents can locate `node` and friends when the host + /// app was launched from Finder with the minimal GUI PATH. + private var cachedShellPath: String? + + init() {} + + func setPermissionServer(_ server: PermissionServer) { + self.permissionServer = server + } + + // MARK: - Public Interface + + func send( + streamId: UUID, + prompt: String, + cwd: String, + sessionId: String?, + model: String?, + spec: ACPClientSpec, + permissionMode: PermissionMode, + clientSessionKey: String + ) -> AsyncStream { + logger.info("[ACP] send streamId=\(streamId.uuidString, privacy: .public) client=\(spec.displayName, privacy: .public) launch=\(spec.launch.displayKind, privacy: .public) model=\(model ?? "", privacy: .public) sessionId=\(sessionId ?? "", privacy: .public) mode=\(String(describing: permissionMode), privacy: .public) cwd=\(cwd, privacy: .public) promptLen=\(prompt.count)") + return AsyncStream { continuation in + let task = Task { + await self.runTurn( + streamId: streamId, + prompt: prompt, + cwd: cwd, + incomingSessionId: sessionId, + model: model, + spec: spec, + permissionMode: permissionMode, + clientSessionKey: clientSessionKey, + continuation: continuation + ) + } + continuation.onTermination = { _ in + task.cancel() + Task { await self.handleStreamTermination(streamId: streamId) } + } + } + } + + func finalize(streamId: UUID) { + guard var entry = streams.removeValue(forKey: streamId) else { return } + logger.info("[ACP] finalize streamId=\(streamId.uuidString, privacy: .public) pid=\(entry.process.processIdentifier) pending=\(entry.pending.count) running=\(entry.process.isRunning)") + try? entry.stdin.close() + if entry.process.isRunning { + entry.process.terminate() + } + for (_, cont) in entry.pending { + cont.resume(throwing: ACPError.streamClosed) + } + entry.pending.removeAll() + } + + func handleStreamTermination(streamId: UUID) { + logger.info("[ACP] stream consumer detached streamId=\(streamId.uuidString, privacy: .public)") + finalize(streamId: streamId) + } + + func cancel(streamId: UUID) { + // Attempt graceful cancel via session/cancel notification, then kill the process. + if let entry = streams[streamId], let sid = entry.agentSessionId { + let frame: [String: Any] = [ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": ["sessionId": sid] + ] + if let data = try? JSONSerialization.data(withJSONObject: frame), + let line = String(data: data, encoding: .utf8) { + _ = try? entry.stdin.write(contentsOf: Data((line + "\n").utf8)) + } + } + finalize(streamId: streamId) + } + + func consumeStderr(for streamId: UUID) -> String? { + guard let entry = streams[streamId] else { return nil } + let trimmed = entry.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func cleanup() { + for (id, _) in streams { + finalize(streamId: id) + } + } + + /// One-shot probe that spawns the agent, runs `initialize` + `session/new`, + /// reads the model selector (if any), then tears the process down. Used + /// at install time to populate the model picker without requiring a real + /// turn. + /// + /// Bounded by `timeout` (default 20s) so a hung agent can't wedge the + /// UI's fetch button. + func probeModels( + spec: ACPClientSpec, + cwd: String, + timeout: Duration = .seconds(20) + ) async throws -> ACPModelConfig? { + let streamId = UUID() + logger.info("[ACP] probe start: \(spec.displayName, privacy: .public) (launch=\(spec.launch.displayKind, privacy: .public)) cwd=\(cwd, privacy: .public)") + + let (process, stdin, stdout, stderr) = try await spawn(spec: spec, model: nil, cwd: cwd) + logger.info("[ACP] probe spawned pid=\(process.processIdentifier) for \(spec.displayName, privacy: .public)") + + let entry = StreamEntry( + process: process, + stdin: stdin, + spec: spec, + cwd: cwd, + clientSessionKey: "" + ) + streams[streamId] = entry + + startStderrReader(streamId: streamId, stderr: stderr) + let readerTask = Self.spawnStdoutReader(streamId: streamId, stdout: stdout, service: self) + process.terminationHandler = { [weak self] _ in + Task.detached { await self?.handleProcessExit(streamId: streamId) } + } + + do { + let result = try await withThrowingTaskGroup(of: ACPModelConfig?.self) { group in + group.addTask { + try await self.runProbeSequence(streamId: streamId, spec: spec, cwd: cwd) + } + group.addTask { + try await Task.sleep(for: timeout) + throw ACPError.probeTimeout(seconds: Int(timeout.components.seconds)) + } + let value = try await group.next() ?? nil + group.cancelAll() + return value + } + readerTask.cancel() + finalize(streamId: streamId) + return result + } catch { + let stderrSnapshot = streams[streamId]?.stderr.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !stderrSnapshot.isEmpty { + logger.error("[ACP] probe stderr for \(spec.displayName, privacy: .public): \(stderrSnapshot, privacy: .public)") + } + readerTask.cancel() + finalize(streamId: streamId) + throw error + } + } + + private func runProbeSequence( + streamId: UUID, + spec: ACPClientSpec, + cwd: String + ) async throws -> ACPModelConfig? { + logger.info("[ACP] probe → initialize \(spec.displayName, privacy: .public)") + _ = try await sendRequest( + streamId: streamId, + method: "initialize", + params: [ + "protocolVersion": .number(1), + "clientCapabilities": .object([ + "fs": .object([ + "readTextFile": .bool(true), + "writeTextFile": .bool(true) + ]) + ]) + ] + ) + logger.info("[ACP] probe ← initialize ok \(spec.displayName, privacy: .public)") + + logger.info("[ACP] probe → session/new \(spec.displayName, privacy: .public)") + let newResult = try await sendRequest( + streamId: streamId, + method: "session/new", + params: [ + "cwd": .string(cwd), + "mcpServers": .array([]) + ] + ) + let optionCount = newResult.objectValue?["configOptions"]?.arrayValue?.count ?? 0 + logger.info("[ACP] probe ← session/new ok \(spec.displayName, privacy: .public) configOptions=\(optionCount)") + + let config = Self.parseModelConfig(from: newResult) + if let config { + logger.info("[ACP] probe parsed model selector configId=\(config.configId, privacy: .public) current=\(config.currentValue ?? "nil", privacy: .public) models=\(config.options.count) [\(Self.modelListDescription(config.options), privacy: .public)]") + } else { + logger.info("[ACP] probe found no model selector for \(spec.displayName, privacy: .public)") + } + return config + } + + // MARK: - Turn Runner + + private func runTurn( + streamId: UUID, + prompt: String, + cwd: String, + incomingSessionId: String?, + model: String?, + spec: ACPClientSpec, + permissionMode: PermissionMode, + clientSessionKey: String, + continuation: AsyncStream.Continuation + ) async { + do { + let (process, stdin, stdout, stderr) = try await spawn(spec: spec, model: model, cwd: cwd) + var entry = StreamEntry( + process: process, + stdin: stdin, + spec: spec, + cwd: cwd, + clientSessionKey: clientSessionKey + ) + entry.continuation = continuation + streams[streamId] = entry + + // Start background readers detached so they don't fight the writer + // for ACPService actor reentrance — they only hop onto the actor + // when delivering a line. + startStderrReader(streamId: streamId, stderr: stderr) + let readerTask = Self.spawnStdoutReader(streamId: streamId, stdout: stdout, service: self) + process.terminationHandler = { [weak self] _ in + Task.detached { await self?.handleProcessExit(streamId: streamId) } + } + let heartbeat = Task.detached { [weak self, displayName = spec.displayName, launchKind = spec.launch.displayKind] in + let started = Date() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + if Task.isCancelled { break } + let elapsed = Int(Date().timeIntervalSince(started)) + await self?.heartbeatTick(displayName: displayName, elapsed: elapsed, launchKind: launchKind) + } + } + + // 1. initialize + let initResult = try await sendRequest( + streamId: streamId, + method: "initialize", + params: [ + "protocolVersion": .number(1), + "clientCapabilities": .object([ + "fs": .object([ + "readTextFile": .bool(true), + "writeTextFile": .bool(true) + ]) + ]) + ] + ) + heartbeat.cancel() + logger.info("[ACP] initialize ok for \(spec.displayName, privacy: .public): \(initResult.shortDescription, privacy: .public)") + + // Surface the agent identity as a system event so the chat header + // matches the Claude path. + continuation.yield(.system(SystemEvent( + subtype: "init", + sessionId: incomingSessionId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + + // 2. Always `session/new`. We could `session/load` here if the agent + // advertises `loadSession`, but ACP's load-replay is only useful + // when the same agent *process* is being reattached. Our current + // architecture spawns a fresh subprocess per turn, so `session/load` + // on agents that persist sessions to disk (Gemini CLI) makes them + // re-emit the persisted conversation during the next `session/prompt`, + // which surfaces in the UI as prior answers being prepended to new + // ones. Each turn therefore starts a fresh ACP session; conversation + // continuity is preserved by RxCode's chat history, not by the agent. + let agentSessionId: String + var modelConfig: ACPModelConfig? + let sessionMethod = "session/new" + let newParams: [String: JSONValue] = [ + "cwd": .string(cwd), + "mcpServers": .array([]) + ] + let newResult = try await sendRequest(streamId: streamId, method: "session/new", params: newParams) + guard let sid = newResult.objectValue?["sessionId"]?.stringValue else { + throw ACPError.protocolMismatch("session/new returned no sessionId") + } + modelConfig = Self.parseModelConfig(from: newResult) + agentSessionId = sid + _ = incomingSessionId // intentionally unused — see comment above + mutateStream(streamId) { + $0.agentSessionId = agentSessionId + $0.modelConfigId = modelConfig?.configId + } + + // Tell AppState about the discovered model list so it can refresh + // the picker and persist it to disk. + if let modelConfig { + logger.info("[ACP] discovered model selector for \(spec.displayName, privacy: .public) via \(sessionMethod, privacy: .public) configId=\(modelConfig.configId, privacy: .public) current=\(modelConfig.currentValue ?? "nil", privacy: .public) models=\(modelConfig.options.count) [\(Self.modelListDescription(modelConfig.options), privacy: .public)]") + continuation.yield(.acpModelsDiscovered(ACPModelsDiscoveredEvent( + clientId: spec.id, + config: modelConfig + ))) + } else { + logger.info("[ACP] no model selector discovered for \(spec.displayName, privacy: .public) via \(sessionMethod, privacy: .public)") + } + + // Apply the user's model choice via the spec-compliant route when + // the agent advertised a selector. Env-var-at-spawn covers the + // fallback case for older agents. + if let modelConfig, + let model, + !model.isEmpty, + modelConfig.options.contains(where: { $0.value == model }), + modelConfig.currentValue != model { + do { + _ = try await sendRequest( + streamId: streamId, + method: "session/set_config_option", + params: [ + "sessionId": .string(agentSessionId), + "configId": .string(modelConfig.configId), + "value": .string(model) + ] + ) + } catch { + logger.warning("[ACP] session/set_config_option failed for model \(model, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + // Emit the agent's session id so the chat UI replaces its pending placeholder. + continuation.yield(.system(SystemEvent( + subtype: "session_started", + sessionId: agentSessionId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + + // 3. session/prompt — blocks until the turn completes. + // Flip the gate AFTER the optional `session/set_config_option` + // exchange so we don't accept stray updates before the prompt is + // actually in flight. + mutateStream(streamId) { $0.acceptingUpdates = true } + let promptResult = try await sendRequest( + streamId: streamId, + method: "session/prompt", + params: [ + "sessionId": .string(agentSessionId), + "prompt": .array([.object([ + "type": .string("text"), + "text": .string(prompt) + ])]) + ] + ) + + let stopReason = promptResult.objectValue?["stopReason"]?.stringValue ?? "end_turn" + continuation.yield(.result(ResultEvent( + durationMs: nil, + totalCostUsd: nil, + sessionId: agentSessionId, + isError: stopReason == "refusal", + totalTurns: nil, + usage: nil, + contextWindow: nil + ))) + + readerTask.cancel() + continuation.finish() + finalize(streamId: streamId) + } catch { + logger.error("[ACP] turn failed: \(error.localizedDescription, privacy: .public)") + continuation.yield(.user(UserMessage( + toolUseId: nil, + content: "ACP error: \(error.localizedDescription)", + isError: true + ))) + continuation.finish() + finalize(streamId: streamId) + } + } + + private func heartbeatTick(displayName: String, elapsed: Int, launchKind: String) { + let hint = launchKind == "npx" + ? " (npx cold-start is ~10s; first run may install the package)" + : "" + logger.notice("[ACP] still waiting for \(displayName, privacy: .public) response after \(elapsed)s\(hint, privacy: .public)") + } + + // MARK: - Process Spawn + + private func spawn(spec: ACPClientSpec, model: String?, cwd: String) async + throws -> (Process, FileHandle, FileHandle, FileHandle) + { + let (executable, args, baseEnv) = try resolveLaunch(spec.launch) + let allArgs = args + spec.extraArgs + logger.info("[ACP] spawn exec=\(executable, privacy: .public) args=[\(allArgs.joined(separator: " "), privacy: .public)] cwd=\(cwd, privacy: .public)") + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = allArgs + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + + var env = await resolvedEnvironment() + env.merge(baseEnv) { _, new in new } + env.merge(spec.extraEnv) { _, new in new } + if let envVar = spec.modelEnvVar, let model, !model.isEmpty { + env[envVar] = model + logger.info("[ACP] spawn injecting model env \(envVar, privacy: .public)=\(model, privacy: .public)") + } + process.environment = env + logger.info("[ACP] spawn PATH=\(env["PATH"] ?? "", privacy: .public)") + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + logger.info("[ACP] spawn ok pid=\(process.processIdentifier) for \(spec.displayName, privacy: .public)") + } catch { + logger.error("[ACP] spawn FAILED exec=\(executable, privacy: .public): \(error.localizedDescription, privacy: .public)") + throw error + } + return (process, stdinPipe.fileHandleForWriting, + stdoutPipe.fileHandleForReading, stderrPipe.fileHandleForReading) + } + + private func resolvedEnvironment() async -> [String: String] { + var env = ProcessInfo.processInfo.environment + if let cachedShellPath { + env["PATH"] = cachedShellPath + return env + } + let shellPath = await readUserShellPath() + if let shellPath, !shellPath.isEmpty { + cachedShellPath = shellPath + env["PATH"] = shellPath + logger.info("[ACP] resolved login shell PATH (\(shellPath.split(separator: ":").count) entries)") + } else { + logger.warning("[ACP] could not read login shell PATH; using GUI PATH=\(env["PATH"] ?? "", privacy: .public)") + } + return env + } + + private func readUserShellPath() async -> String? { + await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-ilc", "print -rn -- $PATH"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = stdout.fileHandleForReading.readDataToEndOfFile() + let out = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (out?.isEmpty ?? true) ? nil : out + } catch { + return nil + } + }.value + } + + private func resolveLaunch(_ launch: ACPClientSpec.LaunchKind) + throws -> (String, [String], [String: String]) + { + switch launch { + case .npx(let package, let args, let env): + // `npx -y ` so it runs without an install prompt. + return ("/usr/bin/env", ["npx", "-y", package] + args, env) + case .uvx(let package, let args, let env): + return ("/usr/bin/env", ["uvx", package] + args, env) + case .binary(let path, let args, let env): + return (path, args, env) + case .custom(let command, let args, let env): + return (command, args, env) + } + } + + // MARK: - JSON-RPC Framing + + private func sendRequest(streamId: UUID, method: String, params: [String: JSONValue]) + async throws -> JSONValue + { + guard streams[streamId] != nil else { throw ACPError.streamClosed } + + let id = nextRequestId(streamId: streamId) + let frame: [String: JSONValue] = [ + "jsonrpc": .string("2.0"), + "id": .number(Double(id)), + "method": .string(method), + "params": .object(params) + ] + try writeFrame(streamId: streamId, frame: .object(frame)) + logger.info("[ACP] → \(method, privacy: .public) id=\(id) stream=\(streamId.uuidString.prefix(8), privacy: .public)") + + return try await withCheckedThrowingContinuation { cont in + mutateStream(streamId) { $0.pending[id] = cont } + } + } + + private func sendResult(streamId: UUID, id: JSONValue, result: JSONValue) { + let frame: [String: JSONValue] = [ + "jsonrpc": .string("2.0"), + "id": id, + "result": result + ] + try? writeFrame(streamId: streamId, frame: .object(frame)) + } + + private func sendError(streamId: UUID, id: JSONValue, code: Int, message: String) { + let frame: [String: JSONValue] = [ + "jsonrpc": .string("2.0"), + "id": id, + "error": .object([ + "code": .number(Double(code)), + "message": .string(message) + ]) + ] + try? writeFrame(streamId: streamId, frame: .object(frame)) + } + + private func writeFrame(streamId: UUID, frame: JSONValue) throws { + guard let entry = streams[streamId] else { throw ACPError.streamClosed } + let data = try JSONEncoder().encode(frame) + var line = data + line.append(0x0A) // newline + try entry.stdin.write(contentsOf: line) + } + + private func nextRequestId(streamId: UUID) -> Int { + var id = 0 + mutateStream(streamId) { entry in + id = entry.nextId + entry.nextId += 1 + } + return id + } + + private func mutateStream(_ streamId: UUID, _ mutate: (inout StreamEntry) -> Void) { + guard var entry = streams[streamId] else { return } + mutate(&entry) + streams[streamId] = entry + } + + // MARK: - Read Loop + // + // We deliberately avoid `FileHandle.bytes.lines`: in practice that + // AsyncSequence does not reliably drain Pipe-backed stdout on Darwin — + // bytes can sit indefinitely until the writer closes the pipe. Instead we + // use `readabilityHandler`, which is GCD-backed and fires as soon as data + // arrives, and split into newline-delimited frames ourselves. + + private static func spawnStdoutReader( + streamId: UUID, + stdout: FileHandle, + service: ACPService + ) -> Task { + let chunkStream = AsyncStream { continuation in + stdout.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + continuation.finish() + handle.readabilityHandler = nil + } else { + continuation.yield(data) + } + } + continuation.onTermination = { _ in + stdout.readabilityHandler = nil + } + } + + return Task.detached { [weak service] in + let shortId = String(streamId.uuidString.prefix(8)) + await service?.logReaderStarted(shortId: shortId) + var buffer = Data() + for await chunk in chunkStream { + if Task.isCancelled { + await service?.logReaderCancelled(shortId: shortId) + stdout.readabilityHandler = nil + return + } + buffer.append(chunk) + while let nlIdx = buffer.firstIndex(of: 0x0A) { + let lineData = buffer.subdata(in: buffer.startIndex..", privacy: .public)") + return + } + guard let obj = value.objectValue else { return } + + // Response (has "id" and "result"/"error", no "method"). + if obj["method"] == nil, let idVal = obj["id"], let idInt = idVal.intValue { + resolveResponse(streamId: streamId, id: idInt, body: obj) + return + } + + // Request or notification (has "method"). + guard let method = obj["method"]?.stringValue else { return } + let params = obj["params"] ?? .null + + if let idVal = obj["id"] { + // Server-initiated request: respond. + await handleAgentRequest(streamId: streamId, id: idVal, method: method, params: params) + } else { + // Notification. + handleAgentNotification(streamId: streamId, method: method, params: params) + } + } + + private func resolveResponse(streamId: UUID, id: Int, body: [String: JSONValue]) { + var cont: CheckedContinuation? + mutateStream(streamId) { entry in + cont = entry.pending.removeValue(forKey: id) + } + guard let cont else { + logger.warning("[ACP] ← response id=\(id) had no pending continuation stream=\(streamId.uuidString.prefix(8), privacy: .public)") + return + } + if let err = body["error"]?.objectValue { + let msg = err["message"]?.stringValue ?? "ACP error" + let code = err["code"]?.intValue ?? -1 + logger.error("[ACP] ← error id=\(id) code=\(code) msg=\(msg, privacy: .public)") + cont.resume(throwing: ACPError.agentError(code: code, message: msg)) + } else { + let result = body["result"] ?? .null + logger.info("[ACP] ← ok id=\(id) result=\(result.shortDescription, privacy: .public)") + cont.resume(returning: result) + } + } + + // MARK: - Agent Notifications + + private func handleAgentNotification(streamId: UUID, method: String, params: JSONValue) { + switch method { + case "session/update": + // Drop updates that arrive before `session/prompt` is sent — those + // are the agent's replay of historical content from `session/load` + // and would duplicate prior messages in the UI. + guard streams[streamId]?.acceptingUpdates == true else { + let kind = params.objectValue?["update"]?.objectValue?["sessionUpdate"]?.stringValue ?? "" + logger.info("[ACP] ⟵ pre-prompt session/update dropped kind=\(kind, privacy: .public)") + return + } + handleSessionUpdate(streamId: streamId, params: params) + default: + logger.warning("[ACP] ⟵ unknown notification: \(method, privacy: .public)") + } + } + + private func handleSessionUpdate(streamId: UUID, params: JSONValue) { + guard let p = params.objectValue, + let update = p["update"]?.objectValue, + let kind = update["sessionUpdate"]?.stringValue, + let continuation = streams[streamId]?.continuation else { + logger.warning("[ACP] session/update missing fields or no continuation stream=\(streamId.uuidString.prefix(8), privacy: .public)") + return + } + + switch kind { + case "agent_message_chunk": + if let text = update["content"]?.objectValue?["text"]?.stringValue { + logger.info("[ACP] ⟵ agent_message_chunk len=\(text.count)") + // Route through the Claude `content_block_delta` / `text_delta` + // pipeline so consecutive chunks accumulate into the same + // streaming assistant bubble via `state.textDeltaBuffer`. The + // `.assistant(.text(_))` path only flushes the first chunk per + // turn (dedup guard in `processStream`), so it was dropping + // every chunk after the first. + continuation.yield(.unknown(Self.textDeltaFrame(text))) + } else { + logger.warning("[ACP] agent_message_chunk had no text content") + } + + case "agent_thought_chunk": + if let text = update["content"]?.objectValue?["text"]?.stringValue { + logger.info("[ACP] ⟵ agent_thought_chunk len=\(text.count)") + // Only the `isThinking` flag flip in `handlePartialEvent` is + // wired up for thinking text; emitting a thinking_delta is + // enough to surface the "thinking…" indicator in the UI. + continuation.yield(.unknown(Self.thinkingDeltaFrame(text))) + } + + case "plan": + let entries = update["entries"]?.arrayValue?.count ?? 0 + logger.info("[ACP] ⟵ plan entries=\(entries)") + handlePlanUpdate(streamId: streamId, update: update, continuation: continuation) + + case "tool_call": + handleToolCall(streamId: streamId, update: update, continuation: continuation) + + case "tool_call_update": + handleToolCallUpdate(streamId: streamId, update: update, continuation: continuation) + + default: + logger.warning("[ACP] ⟵ unhandled sessionUpdate kind: \(kind, privacy: .public)") + } + } + + private func handlePlanUpdate(streamId: UUID, update: [String: JSONValue], + continuation: AsyncStream.Continuation) { + guard let entries = update["entries"]?.arrayValue else { return } + + // Render a markdown checklist so the existing PlanCardView renders it. + var markdown = "# Plan\n\n" + for entry in entries { + guard let obj = entry.objectValue else { continue } + let status = obj["status"]?.stringValue ?? "pending" + let content = obj["content"]?.stringValue ?? "" + let mark: String + switch status { + case "completed": mark = "- [x] " + case "in_progress": mark = "- [~] " + default: mark = "- [ ] " + } + markdown += "\(mark)\(content)\n" + } + + let planId = streams[streamId]?.planSyntheticId ?? "acp-plan" + continuation.yield(.assistant(AssistantMessage( + role: "assistant", + content: [.toolUse( + id: planId, + name: "ExitPlanMode", + input: ["plan": .string(markdown)] + )] + ))) + mutateStream(streamId) { $0.planEmitted = true } + } + + private func handleToolCall(streamId: UUID, update: [String: JSONValue], + continuation: AsyncStream.Continuation) { + guard let toolCallId = update["toolCallId"]?.stringValue else { + logger.warning("[ACP] tool_call missing toolCallId") + return + } + let title = update["title"]?.stringValue ?? update["kind"]?.stringValue ?? "tool" + let rawInput = update["rawInput"]?.objectValue ?? [:] + logger.info("[ACP] ⟵ tool_call id=\(toolCallId, privacy: .public) name=\(title, privacy: .public) inputKeys=[\(rawInput.keys.sorted().joined(separator: ","), privacy: .public)]") + + mutateStream(streamId) { $0.liveToolCalls.insert(toolCallId) } + + continuation.yield(.assistant(AssistantMessage( + role: "assistant", + content: [.toolUse(id: toolCallId, name: title, input: rawInput)] + ))) + } + + private func handleToolCallUpdate(streamId: UUID, update: [String: JSONValue], + continuation: AsyncStream.Continuation) { + guard let toolCallId = update["toolCallId"]?.stringValue else { + logger.warning("[ACP] tool_call_update missing toolCallId") + return + } + let status = update["status"]?.stringValue ?? "completed" + logger.info("[ACP] ⟵ tool_call_update id=\(toolCallId, privacy: .public) status=\(status, privacy: .public)") + + // Compose tool result text from rawOutput or content[] + var resultText = "" + if let raw = update["rawOutput"] { + if let s = raw.stringValue { resultText = s } + else if let data = try? JSONEncoder().encode(raw), + let s = String(data: data, encoding: .utf8) { resultText = s } + } else if let content = update["content"]?.arrayValue { + resultText = content.compactMap { entry -> String? in + guard let obj = entry.objectValue else { return nil } + if obj["type"]?.stringValue == "content", + let inner = obj["content"]?.objectValue, + inner["type"]?.stringValue == "text" { + return inner["text"]?.stringValue + } + return obj["text"]?.stringValue + }.joined(separator: "\n") + } + + let isError = status == "failed" + continuation.yield(.user(UserMessage( + toolUseId: toolCallId, + content: resultText.isEmpty ? (isError ? "Tool failed" : "Done") : resultText, + isError: isError + ))) + } + + // MARK: - Agent Requests (server-initiated) + + private func handleAgentRequest(streamId: UUID, id: JSONValue, method: String, params: JSONValue) async { + logger.info("[ACP] ⟵ agent-request \(method, privacy: .public) stream=\(streamId.uuidString.prefix(8), privacy: .public)") + switch method { + case "fs/read_text_file": + await handleFsReadTextFile(streamId: streamId, id: id, params: params) + case "fs/write_text_file": + await handleFsWriteTextFile(streamId: streamId, id: id, params: params) + case "session/request_permission": + await handleSessionRequestPermission(streamId: streamId, id: id, params: params) + default: + logger.warning("[ACP] unsupported agent-request: \(method, privacy: .public)") + sendError(streamId: streamId, id: id, code: -32601, message: "Method not supported: \(method)") + } + } + + private func handleFsReadTextFile(streamId: UUID, id: JSONValue, params: JSONValue) async { + guard let path = params.objectValue?["path"]?.stringValue else { + sendError(streamId: streamId, id: id, code: -32602, message: "Missing path") + return + } + do { + let line = params.objectValue?["line"]?.intValue + let limit = params.objectValue?["limit"]?.intValue + let content = try Self.readTextFile(path: path, line: line, limit: limit) + sendResult(streamId: streamId, id: id, result: .object(["content": .string(content)])) + } catch { + sendError(streamId: streamId, id: id, code: -32000, message: error.localizedDescription) + } + } + + private func handleFsWriteTextFile(streamId: UUID, id: JSONValue, params: JSONValue) async { + guard let path = params.objectValue?["path"]?.stringValue, + let content = params.objectValue?["content"]?.stringValue else { + sendError(streamId: streamId, id: id, code: -32602, message: "Missing path or content") + return + } + do { + try Self.writeTextFile(path: path, content: content) + sendResult(streamId: streamId, id: id, result: .object([:])) + } catch { + sendError(streamId: streamId, id: id, code: -32000, message: error.localizedDescription) + } + } + + private func handleSessionRequestPermission(streamId: UUID, id: JSONValue, params: JSONValue) async { + guard let entry = streams[streamId] else { + logger.warning("[ACP] permission request arrived for closed stream") + sendError(streamId: streamId, id: id, code: -32000, message: "Stream closed") + return + } + let toolCall = params.objectValue?["toolCall"]?.objectValue ?? [:] + let toolCallId = toolCall["toolCallId"]?.stringValue ?? UUID().uuidString + let toolName = toolCall["title"]?.stringValue ?? toolCall["kind"]?.stringValue ?? "tool" + let toolInput = toolCall["rawInput"]?.objectValue ?? [:] + logger.info("[ACP] permission request tool=\(toolName, privacy: .public) id=\(toolCallId, privacy: .public)") + + guard let server = permissionServer else { + // No permission server registered — auto-allow so the agent doesn't hang. + let optionId = params.objectValue?["options"]?.arrayValue? + .first(where: { $0.objectValue?["kind"]?.stringValue == "allow_once" })? + .objectValue?["optionId"]?.stringValue ?? "allow" + logger.warning("[ACP] no permission server — auto-allowing \(toolName, privacy: .public) via optionId=\(optionId, privacy: .public)") + sendResult(streamId: streamId, id: id, result: .object([ + "outcome": .object([ + "outcome": .string("selected"), + "optionId": .string(optionId) + ]) + ])) + return + } + + let decision = await server.requestDecision( + toolUseId: toolCallId, + sessionId: entry.agentSessionId, + toolName: toolName, + toolInput: toolInput, + mode: nil + ) + logger.info("[ACP] permission decision tool=\(toolName, privacy: .public) decision=\(String(describing: decision), privacy: .public)") + + // Map RxCode decision to an ACP option from the options[] array, defaulting to allow_once / reject_once. + let options = params.objectValue?["options"]?.arrayValue ?? [] + let wantKind: String + switch decision { + case .allow, .allowSessionTool, .allowAlwaysCommand, .allowAndSetMode: + wantKind = "allow_once" + case .deny, .denyWithReason: + wantKind = "reject_once" + } + let chosen = options.first { $0.objectValue?["kind"]?.stringValue == wantKind } + ?? options.first + let optionId = chosen?.objectValue?["optionId"]?.stringValue ?? wantKind + logger.info("[ACP] permission reply wantKind=\(wantKind, privacy: .public) optionId=\(optionId, privacy: .public)") + + sendResult(streamId: streamId, id: id, result: .object([ + "outcome": .object([ + "outcome": .string("selected"), + "optionId": .string(optionId) + ]) + ])) + } + + // MARK: - File Helpers + + private static func readTextFile(path: String, line: Int?, limit: Int?) throws -> String { + let url = URL(fileURLWithPath: path) + let text = try String(contentsOf: url, encoding: .utf8) + if line == nil && limit == nil { return text } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + let start = max(0, (line ?? 1) - 1) + let end = limit.map { min(lines.count, start + $0) } ?? lines.count + guard start < lines.count else { return "" } + return lines[start.. Bool { + initResult.objectValue?["agentCapabilities"]?.objectValue?["loadSession"]?.boolValue ?? false + } + + /// Scans a `session/new` (or `session/load`) response for the first + /// `SessionConfigOption` with `category: "model"` and `type: "select"`, + /// flattening grouped options. + private static func parseModelConfig(from result: JSONValue) -> ACPModelConfig? { + guard let configOptions = result.objectValue?["configOptions"]?.arrayValue else { return nil } + for option in configOptions { + guard let obj = option.objectValue, + obj["category"]?.stringValue == "model", + obj["type"]?.stringValue == "select", + let configId = obj["id"]?.stringValue, + let opts = obj["options"]?.arrayValue + else { continue } + + var flattened: [ACPModelOption] = [] + for entry in opts { + guard let entryObj = entry.objectValue else { continue } + if let groupOpts = entryObj["options"]?.arrayValue { + // SessionConfigSelectGroup — flatten its options. + for groupEntry in groupOpts { + if let parsed = parseSelectOption(groupEntry) { + flattened.append(parsed) + } + } + } else if let parsed = parseSelectOption(entry) { + flattened.append(parsed) + } + } + guard !flattened.isEmpty else { continue } + return ACPModelConfig( + configId: configId, + currentValue: obj["currentValue"]?.stringValue, + options: flattened + ) + } + return nil + } + + /// Wraps a text chunk in the same raw-event shape Claude's CLI emits, so + /// `AppState.handlePartialEvent` accumulates it into `state.textDeltaBuffer`. + private static func textDeltaFrame(_ text: String) -> String { + let payload: [String: Any] = [ + "type": "content_block_delta", + "delta": ["type": "text_delta", "text": text] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let str = String(data: data, encoding: .utf8) else { return "" } + return str + } + + private static func thinkingDeltaFrame(_ text: String) -> String { + let payload: [String: Any] = [ + "type": "content_block_delta", + "delta": ["type": "thinking_delta", "thinking": text] + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let str = String(data: data, encoding: .utf8) else { return "" } + return str + } + + private static func parseSelectOption(_ value: JSONValue) -> ACPModelOption? { + guard let obj = value.objectValue, + let val = obj["value"]?.stringValue, + let name = obj["name"]?.stringValue + else { return nil } + return ACPModelOption( + value: val, + name: name, + description: obj["description"]?.stringValue + ) + } + + private static func modelListDescription(_ options: [ACPModelOption]) -> String { + options.map { option in + option.name == option.value ? option.value : "\(option.value) (\(option.name))" + }.joined(separator: ", ") + } + + // MARK: - Process Lifecycle + + private func startStderrReader(streamId: UUID, stderr: FileHandle) { + // Same rationale as `spawnStdoutReader`: avoid `FileHandle.bytes.lines` + // and pump from `readabilityHandler` instead. + let chunkStream = AsyncStream { continuation in + stderr.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + continuation.finish() + handle.readabilityHandler = nil + } else { + continuation.yield(data) + } + } + continuation.onTermination = { _ in + stderr.readabilityHandler = nil + } + } + + Task.detached { [weak self] in + var buffer = Data() + for await chunk in chunkStream { + if Task.isCancelled { + stderr.readabilityHandler = nil + return + } + buffer.append(chunk) + while let nlIdx = buffer.firstIndex(of: 0x0A) { + let lineData = buffer.subdata(in: buffer.startIndex.. ChatSession? { switch summary.origin { case .cliBacked: @@ -177,7 +181,7 @@ actor PersistenceService { cwd: cwd, projectId: summary.projectId ) - case .legacyRxCode, .codexAppServer: + case .legacyRxCode, .codexAppServer, .acpAgent: return loadLegacySessionSync(projectId: summary.projectId, sessionId: summary.id) } } @@ -217,6 +221,24 @@ actor PersistenceService { .appendingPathComponent("\(projectId.uuidString).json") } + // MARK: - ACP Clients + + func saveACPClients(_ clients: [ACPClientSpec]) throws { + let url = baseURL.appendingPathComponent("acp_clients.json") + try encode(clients, to: url) + } + + func loadACPClients() -> [ACPClientSpec] { + let url = baseURL.appendingPathComponent("acp_clients.json") + return decode([ACPClientSpec].self, from: url) ?? [] + } + + /// Returns the on-disk URL for the cached ACP registry snapshot. The + /// `ACPRegistryService` reads/writes this file directly. + nonisolated func acpRegistrySnapshotURL() -> URL { + AppSupport.bundleScopedURL.appendingPathComponent("acp_registry.json") + } + // MARK: - GitHub User Cache func saveGitHubUser(_ user: GitHubUser) throws { diff --git a/RxCode/Views/ACPIconView.swift b/RxCode/Views/ACPIconView.swift new file mode 100644 index 00000000..c1446a03 --- /dev/null +++ b/RxCode/Views/ACPIconView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import AppKit +import CryptoKit + +/// Renders an ACP client/agent icon from a remote URL. Falls back to an +/// SF Symbol placeholder when the URL is missing or the image fails to +/// load. Icons are cached on disk under `~/Library/Caches/RxCode/acp-icons/` +/// and in memory via `ACPIconCache`. +/// +/// Registry icons are monochrome SVGs designed to be tinted. We mark the +/// loaded `NSImage` as a template so AppKit / SwiftUI render it in the +/// foreground color — white on dark, black on light — and we expose +/// `.foregroundStyle(.primary)` for that effect. +/// +/// SVG: `NSImage(data:)` can miss the format hint for raw SVG bytes, so +/// we write the bytes to a disk cache file using the URL's extension +/// (`.svg` / `.png` / etc.) and load via `NSImage(contentsOf:)`, which +/// dispatches by extension reliably. +struct ACPIconView: View { + let url: String? + var size: CGFloat = 16 + + @State private var image: NSImage? + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .foregroundStyle(.primary) + } else { + Image(systemName: "puzzlepiece.extension") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + } + } + .frame(width: size, height: size) + .task(id: url) { + await loadIcon() + } + } + + private func loadIcon() async { + guard let url, let parsed = URL(string: url) else { + image = nil + return + } + if let cached = await ACPIconCache.shared.image(for: parsed) { + image = cached + return + } + if let onDisk = ACPIconCache.loadFromDisk(parsed) { + await ACPIconCache.shared.store(onDisk, for: parsed) + image = onDisk + return + } + do { + let (data, _) = try await URLSession.shared.data(from: parsed) + guard let nsImage = ACPIconCache.makeImage(from: data, sourceURL: parsed) else { return } + await ACPIconCache.shared.store(nsImage, for: parsed) + image = nsImage + } catch { + // Fall back to placeholder; nothing to log noisily about. + } + } +} + +actor ACPIconCache { + static let shared = ACPIconCache() + private var cache: [URL: NSImage] = [:] + + func image(for url: URL) -> NSImage? { cache[url] } + + func store(_ image: NSImage, for url: URL) { cache[url] = image } + + // MARK: - Disk Cache + + /// Stable cache directory inside the app's Caches container. + private static let diskRoot: URL = { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let dir = base.appendingPathComponent("RxCode/acp-icons", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + private static func diskPath(for url: URL) -> URL { + let digest = SHA256.hash(data: Data(url.absoluteString.utf8)) + let hex = digest.compactMap { String(format: "%02x", $0) }.joined() + let ext = url.pathExtension.isEmpty ? "img" : url.pathExtension.lowercased() + return diskRoot.appendingPathComponent("\(hex).\(ext)") + } + + /// Returns a template-marked NSImage for the cached file at the URL's hash, if present. + nonisolated static func loadFromDisk(_ url: URL) -> NSImage? { + let path = diskPath(for: url) + guard FileManager.default.fileExists(atPath: path.path), + let image = NSImage(contentsOf: path) else { return nil } + image.isTemplate = true + return image + } + + /// Persists raw bytes to disk using `url`'s extension as the format hint, + /// then loads the result via `NSImage(contentsOf:)`. Marks the image as a + /// template so it tints to the surrounding foreground color. + nonisolated static func makeImage(from data: Data, sourceURL: URL) -> NSImage? { + let path = diskPath(for: sourceURL) + do { + try data.write(to: path, options: .atomic) + } catch { + // Disk write failure shouldn't block rendering — fall back to in-memory decode. + if let image = NSImage(data: data) { + image.isTemplate = true + return image + } + return nil + } + guard let image = NSImage(contentsOf: path) else { return nil } + image.isTemplate = true + return image + } +} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 81480e42..23595565 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -483,22 +483,32 @@ struct ChatToolbarControls: View { Menu { Section("Model Picker") { - ForEach(appState.availableAgentModelSections(), id: \.provider) { section in - Section(section.provider.displayName) { + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + Section { ForEach(section.models, id: \.key) { model in Button { appState.setSessionModel(model.id, provider: model.provider, in: windowState) } label: { let isSelected = effectiveProvider == model.provider && effectiveModel == model.id - Text(isSelected ? "\(model.displayName) ✓" : model.displayName) + if let iconURL = section.iconURL { + Label { + Text(isSelected ? "\(model.displayName) ✓" : model.displayName) + } icon: { + ACPIconView(url: iconURL, size: 14) + } + } else { + Text(isSelected ? "\(model.displayName) ✓" : model.displayName) + } } } + } header: { + Text(section.title) } } } } label: { controlLabel( - title: "\(effectiveProvider == .codex ? "Codex · " : "")\(AppState.modelDisplayName(effectiveModel, provider: effectiveProvider))", + title: "\(effectiveProvider == .codex ? "Codex · " : "")\(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))", icon: nil, isAccent: false, isActive: false @@ -506,7 +516,7 @@ struct ChatToolbarControls: View { } .menuStyle(.borderlessButton) .fixedSize() - .help("Model: \(effectiveProvider.displayName) · \(AppState.modelDisplayName(effectiveModel, provider: effectiveProvider))") + .help("Model: \(effectiveProvider.displayName) · \(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))") Menu { Section("Effort Picker") { @@ -789,12 +799,17 @@ struct ModelPickerSheet: View { .foregroundStyle(ClaudeTheme.textPrimary) VStack(spacing: 8) { - ForEach(appState.availableAgentModelSections(), id: \.provider) { section in + ForEach(appState.availableAgentModelSections(), id: \.id) { section in VStack(alignment: .leading, spacing: 6) { - Text(section.provider.displayName) - .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textTertiary) - .padding(.horizontal, 4) + HStack(spacing: 6) { + if let iconURL = section.iconURL { + ACPIconView(url: iconURL, size: 14) + } + Text(section.title) + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(.horizontal, 4) ForEach(section.models, id: \.key) { model in let index = flatModels.firstIndex(where: { $0.key == model.key }) ?? 0 diff --git a/RxCode/Views/Settings/ACPClientSettingsTab.swift b/RxCode/Views/Settings/ACPClientSettingsTab.swift new file mode 100644 index 00000000..ed2c609c --- /dev/null +++ b/RxCode/Views/Settings/ACPClientSettingsTab.swift @@ -0,0 +1,486 @@ +import SwiftUI +import RxCodeCore + +struct ACPClientSettingsTab: View { + @Environment(AppState.self) private var appState + + @State private var pendingRemoval: ACPClientSpec? + @State private var editingClient: ACPClientSpec? + @State private var actionError: String? + @State private var registrySearch: String = "" + @State private var installingAgentId: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + Divider() + installedSection + Divider() + registrySection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .task { + if appState.acpRegistry == nil && !appState.acpRegistryLoading { + await appState.refreshACPRegistry() + } + } + .sheet(item: $editingClient) { client in + ACPClientEditorSheet( + client: client, + refreshModels: { id in + await appState.refreshACPClientModels(id: id) + return appState.acpClients.first { $0.id == id } + }, + onSave: { updated in + appState.updateACPClient(updated) + editingClient = nil + }, + onCancel: { editingClient = nil } + ) + } + .alert("Remove ACP client?", isPresented: removalBinding, presenting: pendingRemoval) { client in + Button("Cancel", role: .cancel) { pendingRemoval = nil } + Button("Remove", role: .destructive) { + appState.removeACPClient(id: client.id) + pendingRemoval = nil + } + } message: { client in + Text(verbatim: "“\(client.displayName)” will be removed from RxCode.") + } + .alert("ACP error", isPresented: actionErrorBinding, presenting: actionError) { _ in + Button("OK", role: .cancel) { actionError = nil } + } message: { msg in + Text(verbatim: msg) + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("ACP Clients") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + Text("Manage agents that speak the Agent Client Protocol. Detected from agentclientprotocol.com.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + + // MARK: - Installed + + private var installedSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Installed") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + if appState.acpClients.isEmpty { + Text("No clients installed. Add one from the registry below.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } else { + VStack(spacing: 8) { + ForEach(appState.acpClients) { client in + installedRow(client) + } + } + } + } + } + + private func installedRow(_ client: ACPClientSpec) -> some View { + HStack(alignment: .top, spacing: 12) { + ACPIconView(url: client.iconURL, size: 24) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(client.displayName) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text(client.launch.displayKind) + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(Capsule()) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + Text(modelSummary(for: client)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Toggle("", isOn: Binding( + get: { client.enabled }, + set: { newValue in + var updated = client + updated.enabled = newValue + appState.updateACPClient(updated) + } + )) + .labelsHidden() + .toggleStyle(.switch) + + Button { + editingClient = client + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Edit") + + Button { + pendingRemoval = client + } label: { + Image(systemName: "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Remove") + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + // MARK: - Registry + + private var registrySection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Registry") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Spacer() + Button { + Task { await appState.refreshACPRegistry(forceRefresh: true) } + } label: { + if appState.acpRegistryLoading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.borderless) + .disabled(appState.acpRegistryLoading) + .help("Refresh registry") + } + + if let agents = appState.acpRegistry?.agents, !agents.isEmpty { + let filtered = filteredAgents(agents) + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .font(.system(size: ClaudeTheme.size(11))) + TextField("Search agents", text: $registrySearch) + .textFieldStyle(.plain) + .font(.system(size: ClaudeTheme.size(12))) + if !registrySearch.isEmpty { + Button { + registrySearch = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + + Text(registrySearch.isEmpty + ? "\(agents.count) agents available" + : "\(filtered.count) of \(agents.count) agents") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + if filtered.isEmpty { + Text("No agents match “\(registrySearch)”.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } else { + VStack(spacing: 8) { + ForEach(filtered) { agent in + registryRow(agent) + } + } + } + } else if appState.acpRegistryLoading { + Text("Loading registry…") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } else { + Text("Could not load registry. Check your network connection.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + } + + private func registryRow(_ agent: ACPRegistryAgent) -> some View { + let alreadyInstalled = appState.acpClients.contains { $0.registryId == agent.id } + let isInstalling = installingAgentId == agent.id + return HStack(alignment: .top, spacing: 12) { + ACPIconView(url: agent.icon, size: 24) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(agent.name) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("v\(agent.version)") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + if let license = agent.license { + Text(license) + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + Text(agent.description) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + .lineLimit(3) + Text(distributionSummary(agent.distribution)) + .font(.system(size: ClaudeTheme.size(10), design: .monospaced)) + .foregroundStyle(.tertiary) + } + + if alreadyInstalled { + Text("Installed") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusSuccess) + .padding(.top, 2) + } else if isInstalling { + ProgressView() + .controlSize(.small) + .padding(.top, 2) + } else { + Button("Add") { + installAgent(agent) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(installingAgentId != nil) + } + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + private func installAgent(_ agent: ACPRegistryAgent) { + installingAgentId = agent.id + Task { + defer { installingAgentId = nil } + do { + let spec = try await appState.installACPClient(from: agent) + appState.addACPClient(spec) + } catch { + actionError = error.localizedDescription + } + } + } + + private func filteredAgents(_ agents: [ACPRegistryAgent]) -> [ACPRegistryAgent] { + let query = registrySearch.trimmingCharacters(in: .whitespaces).lowercased() + guard !query.isEmpty else { return agents } + return agents.filter { agent in + if agent.name.lowercased().contains(query) { return true } + if agent.id.lowercased().contains(query) { return true } + if agent.description.lowercased().contains(query) { return true } + return false + } + } + + private func distributionSummary(_ dist: ACPDistribution) -> String { + var parts: [String] = [] + if let npx = dist.npx { parts.append("npx \(npx.package)") } + if let uvx = dist.uvx { parts.append("uvx \(uvx.package)") } + if let bin = dist.binary, !bin.isEmpty { + parts.append("binary (\(bin.keys.sorted().joined(separator: ", ")))") + } + return parts.joined(separator: " · ") + } + + private func modelSummary(for client: ACPClientSpec) -> String { + if let options = client.modelOptions, !options.isEmpty { + return options.map { $0.name.isEmpty ? $0.value : $0.name }.joined(separator: ", ") + } + return client.models.isEmpty ? "Default (agent-chosen)" : client.models.joined(separator: ", ") + } + + // MARK: - Binding Helpers + + private var removalBinding: Binding { + Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } }) + } + private var actionErrorBinding: Binding { + Binding(get: { actionError != nil }, set: { if !$0 { actionError = nil } }) + } +} + +// MARK: - Editor Sheet + +private struct ACPClientEditorSheet: View { + @State var client: ACPClientSpec + let refreshModels: (String) async -> ACPClientSpec? + let onSave: (ACPClientSpec) -> Void + let onCancel: () -> Void + + @State private var envKeyInput: String = "" + @State private var envValueInput: String = "" + @State private var isFetching: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Edit ACP Client") + .font(.system(size: ClaudeTheme.size(14), weight: .semibold)) + + TextField("Display name", text: $client.displayName) + .textFieldStyle(.roundedBorder) + + modelsSection + + VStack(alignment: .leading, spacing: 6) { + Text("Extra environment") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + ForEach(Array(client.extraEnv.keys.sorted()), id: \.self) { key in + HStack { + Text(key) + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) + Text("=") + .foregroundStyle(.secondary) + Text(client.extraEnv[key] ?? "") + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) + .lineLimit(1) + Spacer() + Button { + client.extraEnv.removeValue(forKey: key) + } label: { + Image(systemName: "minus.circle").foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + } + HStack { + TextField("KEY", text: $envKeyInput) + .textFieldStyle(.roundedBorder) + TextField("value", text: $envValueInput) + .textFieldStyle(.roundedBorder) + Button("Add") { + let k = envKeyInput.trimmingCharacters(in: .whitespaces) + guard !k.isEmpty else { return } + client.extraEnv[k] = envValueInput + envKeyInput = "" + envValueInput = "" + } + } + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { onCancel() } + Button("Save") { onSave(client) } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 480) + } + + private var modelsSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("Models") + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + if client.modelConfigId != nil { + Text("Auto-detected") + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(Capsule()) + .foregroundStyle(.secondary) + } + Spacer() + Button { + fetchModels() + } label: { + HStack(spacing: 4) { + if isFetching { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise") + } + Text("Fetch") + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isFetching) + } + + if client.models.isEmpty { + HStack { + Text("Default") + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + } + Text("This agent didn't advertise a model selector. The model picker shows a single \"Default\" entry — the agent picks its own model at runtime. Click Fetch to retry.") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.tertiary) + } else { + ForEach(modelRows, id: \.value) { model in + HStack { + Text(model.name) + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) + Spacer() + } + } + if client.modelConfigId != nil { + Text("This agent reports its models over ACP. RxCode refreshes the list every session start.") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.tertiary) + } + } + } + } + + private var modelRows: [(value: String, name: String)] { + if let options = client.modelOptions, !options.isEmpty { + return options.map { option in + (option.value, option.name.isEmpty ? option.value : option.name) + } + } + return client.models.map { ($0, $0) } + } + + private func fetchModels() { + guard !isFetching else { return } + isFetching = true + Task { + defer { isFetching = false } + if let updated = await refreshModels(client.id) { + client = updated + } + } + } +} diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 263835e4..4338797b 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -45,6 +45,12 @@ struct SettingsView: View { Label("MCP", systemImage: "puzzlepiece.extension") } .tag(4) + + ACPClientSettingsTab() + .tabItem { + Label("ACP Clients", systemImage: "link.circle") + } + .tag(5) } .frame(width: 680, height: 620) .focusable(false) @@ -571,8 +577,8 @@ struct ChatSettingsTab: View { .foregroundStyle(.secondary) Picker("", selection: defaultModelKey) { - ForEach(appState.availableAgentModelSections(), id: \.provider) { section in - Section(section.provider.displayName) { + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + Section(section.title) { ForEach(section.models, id: \.key) { model in Text(model.displayName).tag(model.key) } @@ -608,7 +614,7 @@ struct ChatSettingsTab: View { ?? AgentModel( provider: appState.selectedAgentProvider, id: appState.selectedModel, - displayName: AppState.modelDisplayName(appState.selectedModel, provider: appState.selectedAgentProvider), + displayName: appState.modelDisplayLabel(appState.selectedModel, provider: appState.selectedAgentProvider), description: AppState.modelDescription(appState.selectedModel, provider: appState.selectedAgentProvider) ) } diff --git a/website/public/screenshot/run-profile.png b/website/public/screenshot/run-profile.png new file mode 100644 index 00000000..53fd0ca2 Binary files /dev/null and b/website/public/screenshot/run-profile.png differ