From 26bdb232bbc5a1ea2436a3446b06d10d16665a6e Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 12:42:00 +0800 Subject: [PATCH 1/2] feat: add make lint support --- .github/workflows/test.yaml | 14 ++++++++++++++ .swiftlint.yml | 29 +++++++++++++++++++++++++++++ Makefile | 9 +++++++++ 3 files changed, 52 insertions(+) create mode 100644 .swiftlint.yml create mode 100644 Makefile diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f8a0472..bb3eb33 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,6 +8,20 @@ on: push: jobs: + lint: + name: swiftlint + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install SwiftLint + run: brew list swiftlint >/dev/null 2>&1 || brew install swiftlint + + - name: Run SwiftLint + run: make lint-ci + test-packages: name: swift test (Packages) runs-on: self-hosted diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..e223b02 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,29 @@ +included: + - RxCode + - RxCodeTests + - RxCodeUITests + - RxCodeMobile + - RxCodeMobileTests + - RxCodeMobileUITests + - RxCodeMobileNotificationService + - RxCodeWidget + - Packages/Sources + - Packages/Tests + +excluded: + - build + - DerivedData + - Packages/.build + - Packages/build + +only_rules: + - file_length + +allow_zero_lintable_files: false +check_for_updates: false +reporter: xcode + +file_length: + warning: 800 + error: 1200 + ignore_comment_only_lines: true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f8bdfa0 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +SWIFTLINT ?= swiftlint + +.PHONY: lint lint-ci + +lint: + $(SWIFTLINT) lint --config .swiftlint.yml --quiet --no-cache + +lint-ci: + $(SWIFTLINT) lint --config .swiftlint.yml --reporter github-actions-logging --quiet --no-cache From 280f463dfbc5d2e5e489b8fd80dff22c2fc3f3e4 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 21 May 2026 13:19:09 +0800 Subject: [PATCH 2/2] fix: refactor code --- .../RxCodeChatKit/AtFileSearchBar.swift | 247 + .../RxCodeChatKit/InputBarView+Queue.swift | 178 + .../Sources/RxCodeChatKit/InputBarView.swift | 173 +- .../RxCodeChatKit/SlashCommandBar.swift | 241 - .../Protocol/Payload+RemoteManagement.swift | 701 ++ .../Protocol/Payload+Sessions.swift | 642 ++ .../Sources/RxCodeSync/Protocol/Payload.swift | 1335 --- RxCode/App/AppState+Agents.swift | 592 ++ RxCode/App/AppState+CrossProject.swift | 895 ++ RxCode/App/AppState+Helpers.swift | 562 ++ RxCode/App/AppState+Lifecycle.swift | 482 ++ RxCode/App/AppState+Messaging.swift | 501 ++ RxCode/App/AppState+MobileRemote.swift | 702 ++ RxCode/App/AppState+MobileSnapshots.swift | 797 ++ RxCode/App/AppState+MobileSync.swift | 806 ++ RxCode/App/AppState+Model.swift | 218 + RxCode/App/AppState+Project.swift | 324 + RxCode/App/AppState+SessionLifecycle.swift | 625 ++ RxCode/App/AppState+Stream.swift | 646 ++ RxCode/App/AppState+Worktree.swift | 466 + RxCode/App/AppState.swift | 7552 +---------------- RxCode/Services/ACPService+Helpers.swift | 313 + RxCode/Services/ACPService+Protocol.swift | 455 + RxCode/Services/ACPService+Spawn.swift | 101 + RxCode/Services/ACPService+Turn.swift | 514 ++ RxCode/Services/ACPService.swift | 1379 +-- RxCode/Services/ClaudeService+Discovery.swift | 230 + RxCode/Services/ClaudeService+Process.swift | 707 ++ RxCode/Services/ClaudeService+Summaries.swift | 208 + RxCode/Services/ClaudeService.swift | 1160 +-- RxCode/Services/CodexAppServer+Parsing.swift | 328 + RxCode/Services/CodexAppServer+Process.swift | 148 + RxCode/Services/CodexAppServer+Protocol.swift | 174 + .../Services/CodexAppServer+Summaries.swift | 280 + RxCode/Services/CodexAppServer+Turn.swift | 328 + RxCode/Services/CodexAppServer.swift | 1271 +-- .../MobileSyncService+EventDispatch.swift | 363 + .../MobileSyncService+LiveActivity.swift | 383 + RxCode/Services/MobileSyncService.swift | 786 +- RxCode/Views/ChatSettingsTab.swift | 451 + RxCode/Views/ChatToolbarComponents.swift | 362 + .../Views/Inspector/ChangeSectionViews.swift | 351 + RxCode/Views/Inspector/ChangesView.swift | 440 + .../RightInspectorHeaderControls.swift | 187 + .../Views/Inspector/RightInspectorPanel.swift | 1100 --- .../Views/Inspector/ThisThreadDiffView.swift | 134 + RxCode/Views/MainView.swift | 537 -- RxCode/Views/ModelEffortPickerSheets.swift | 187 + .../RunProfile/RunConfigurationsView.swift | 622 +- .../RunProfileDetailForm+Environments.swift | 198 + .../RunProfileDetailForm+Steps.swift | 100 + .../RunProfile/RunProfileDetailForm.swift | 299 + RxCode/Views/SettingsMemoryViews.swift | 441 + RxCode/Views/SettingsView.swift | 884 -- .../State/MobileAppState+Inbound.swift | 533 ++ .../State/MobileAppState+Intents.swift | 282 + .../State/MobileAppState+Pairing.swift | 117 + .../State/MobileAppState+Persistence.swift | 129 + .../State/MobileAppState+RemoteConfig.swift | 296 + RxCodeMobile/State/MobileAppState+Sync.swift | 418 + RxCodeMobile/State/MobileAppState.swift | 1775 +--- RxCodeMobile/Views/MobileChatView.swift | 370 - .../Views/MobileStreamingIndicator.swift | 54 + RxCodeMobile/Views/QueuedMessagesSheet.swift | 56 + RxCodeMobile/Views/RenameThreadSheet.swift | 69 + RxCodeMobile/Views/ThreadTodoSheet.swift | 211 + 66 files changed, 19399 insertions(+), 19017 deletions(-) create mode 100644 Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift create mode 100644 Packages/Sources/RxCodeChatKit/InputBarView+Queue.swift create mode 100644 Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift create mode 100644 Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift create mode 100644 RxCode/App/AppState+Agents.swift create mode 100644 RxCode/App/AppState+CrossProject.swift create mode 100644 RxCode/App/AppState+Helpers.swift create mode 100644 RxCode/App/AppState+Lifecycle.swift create mode 100644 RxCode/App/AppState+Messaging.swift create mode 100644 RxCode/App/AppState+MobileRemote.swift create mode 100644 RxCode/App/AppState+MobileSnapshots.swift create mode 100644 RxCode/App/AppState+MobileSync.swift create mode 100644 RxCode/App/AppState+Model.swift create mode 100644 RxCode/App/AppState+Project.swift create mode 100644 RxCode/App/AppState+SessionLifecycle.swift create mode 100644 RxCode/App/AppState+Stream.swift create mode 100644 RxCode/App/AppState+Worktree.swift create mode 100644 RxCode/Services/ACPService+Helpers.swift create mode 100644 RxCode/Services/ACPService+Protocol.swift create mode 100644 RxCode/Services/ACPService+Spawn.swift create mode 100644 RxCode/Services/ACPService+Turn.swift create mode 100644 RxCode/Services/ClaudeService+Discovery.swift create mode 100644 RxCode/Services/ClaudeService+Process.swift create mode 100644 RxCode/Services/ClaudeService+Summaries.swift create mode 100644 RxCode/Services/CodexAppServer+Parsing.swift create mode 100644 RxCode/Services/CodexAppServer+Process.swift create mode 100644 RxCode/Services/CodexAppServer+Protocol.swift create mode 100644 RxCode/Services/CodexAppServer+Summaries.swift create mode 100644 RxCode/Services/CodexAppServer+Turn.swift create mode 100644 RxCode/Services/MobileSyncService+EventDispatch.swift create mode 100644 RxCode/Services/MobileSyncService+LiveActivity.swift create mode 100644 RxCode/Views/ChatSettingsTab.swift create mode 100644 RxCode/Views/ChatToolbarComponents.swift create mode 100644 RxCode/Views/Inspector/ChangeSectionViews.swift create mode 100644 RxCode/Views/Inspector/ChangesView.swift create mode 100644 RxCode/Views/Inspector/RightInspectorHeaderControls.swift create mode 100644 RxCode/Views/Inspector/ThisThreadDiffView.swift create mode 100644 RxCode/Views/ModelEffortPickerSheets.swift create mode 100644 RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift create mode 100644 RxCode/Views/RunProfile/RunProfileDetailForm+Steps.swift create mode 100644 RxCode/Views/RunProfile/RunProfileDetailForm.swift create mode 100644 RxCode/Views/SettingsMemoryViews.swift create mode 100644 RxCodeMobile/State/MobileAppState+Inbound.swift create mode 100644 RxCodeMobile/State/MobileAppState+Intents.swift create mode 100644 RxCodeMobile/State/MobileAppState+Pairing.swift create mode 100644 RxCodeMobile/State/MobileAppState+Persistence.swift create mode 100644 RxCodeMobile/State/MobileAppState+RemoteConfig.swift create mode 100644 RxCodeMobile/State/MobileAppState+Sync.swift create mode 100644 RxCodeMobile/Views/MobileStreamingIndicator.swift create mode 100644 RxCodeMobile/Views/QueuedMessagesSheet.swift create mode 100644 RxCodeMobile/Views/RenameThreadSheet.swift create mode 100644 RxCodeMobile/Views/ThreadTodoSheet.swift diff --git a/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift b/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift new file mode 100644 index 0000000..138b417 --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/AtFileSearchBar.swift @@ -0,0 +1,247 @@ +import SwiftUI +import RxCodeCore + +#if os(macOS) + +// MARK: - @ File Search Popup + +struct AtFilePopup: View { + let entries: [AtFileEntry] + let onSelect: (String) -> Void + @Binding var selectedIndex: Int + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("File Search", bundle: .module) + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + Text("\(entries.count)") + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + + Divider() + .foregroundStyle(ClaudeTheme.borderSubtle) + + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + fileRowButton(entry, isSelected: index == selectedIndex) + .id(index) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: selectedIndex) { _, newValue in + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + } + .frame(height: 320) + .background(ClaudeTheme.surfaceElevated) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium)) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium) + .strokeBorder(ClaudeTheme.border, lineWidth: 1) + ) + .shadow(color: ClaudeTheme.shadowColor, radius: 12, y: -4) + } + + @ViewBuilder + private func fileRowButton(_ entry: AtFileEntry, isSelected: Bool) -> some View { + Button { + onSelect(entry.relativePath) + } label: { + HStack(spacing: 10) { + Image(systemName: entry.icon) + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(isSelected ? ClaudeTheme.accent : entry.iconColor) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textPrimary) + + if !entry.directory.isEmpty { + Text(entry.directory) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + } + } + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? ClaudeTheme.accentSubtle : Color.clear) + } + +} + +// MARK: - AtFileEntry + +struct AtFileEntry: Identifiable { + let id: String // relativePath + let name: String // file name + let directory: String // parent directory path + let relativePath: String + + var icon: String { + let ext = (name as NSString).pathExtension.lowercased() + switch ext { + case "swift": return "swift" + case "js", "jsx", "ts", "tsx": return "chevron.left.forwardslash.chevron.right" + case "json": return "curlybraces" + case "md", "txt": return "doc.text" + case "png", "jpg", "jpeg", "svg", "pdf": return "photo" + case "css", "scss": return "paintbrush" + case "html": return "globe" + case "yaml", "yml", "toml": return "gearshape" + default: return "doc" + } + } + + var iconColor: Color { + let ext = (name as NSString).pathExtension.lowercased() + switch ext { + case "swift": return .orange + case "js", "jsx": return .yellow + case "ts", "tsx": return .blue + case "json": return ClaudeTheme.statusSuccess + case "css", "scss": return .pink + case "html": return ClaudeTheme.statusError + case "png", "jpg", "jpeg", "svg", "pdf": return .purple + default: return ClaudeTheme.textTertiary + } + } +} + +// MARK: - AtFileSearch + +enum AtFileSearch { + private nonisolated static let ignoredNames: Set = [ + ".git", ".build", ".swiftpm", "DerivedData", + "node_modules", ".DS_Store", "Pods", + "xcuserdata", ".xcodeproj", ".xcworkspace", + ] + + // File list cache keyed by project path. Populated once per project on first use + // or proactively via prefetch(projectPath:). Filtering against the cached list is + // cheap (in-memory), so typing after the first @ character is fast. + private static var fileListCache: [String: [AtFileEntry]] = [:] + private static var prefetchingPaths: Set = [] + + /// Pre-warms the file cache for a project in the background. + /// Call when a project is selected so the cache is ready when the user types @. + static func prefetch(projectPath: String) { + guard fileListCache[projectPath] == nil, !prefetchingPaths.contains(projectPath) else { return } + prefetchingPaths.insert(projectPath) + Task { + let files = await Task.detached(priority: .utility) { + AtFileSearch.collectFiles(at: projectPath, basePath: projectPath, maxDepth: 6) + }.value + fileListCache[projectPath] = files + prefetchingPaths.remove(projectPath) + } + } + + /// Invalidates the cached file list for a project (e.g. after file-tree changes). + static func invalidate(for projectPath: String) { + fileListCache.removeValue(forKey: projectPath) + } + + static func search(query: String, projectPath: String, maxResults: Int = 20) -> [AtFileEntry] { + // Use the cached file list; fall back to a synchronous scan only if the + // prefetch hasn't finished yet (should be rare after the first project open). + let allFiles: [AtFileEntry] + if let cached = fileListCache[projectPath] { + allFiles = cached + } else { + allFiles = collectFiles(at: projectPath, basePath: projectPath, maxDepth: 6) + fileListCache[projectPath] = allFiles + } + + let q = query.lowercased() + guard !q.isEmpty else { + return Array(allFiles.prefix(maxResults)) + } + + // Filename match takes priority, path match is secondary + var nameMatches: [AtFileEntry] = [] + var pathMatches: [AtFileEntry] = [] + + for entry in allFiles { + if entry.name.lowercased().contains(q) { + nameMatches.append(entry) + } else if entry.relativePath.lowercased().contains(q) { + pathMatches.append(entry) + } + } + + let combined = nameMatches + pathMatches + return Array(combined.prefix(maxResults)) + } + + private nonisolated static func collectFiles( + at path: String, + basePath: String, + maxDepth: Int, + currentDepth: Int = 0 + ) -> [AtFileEntry] { + guard currentDepth <= maxDepth else { return [] } + + let fm = FileManager.default + guard let contents = try? fm.contentsOfDirectory( + at: URL(fileURLWithPath: path), + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var results: [AtFileEntry] = [] + + for url in contents.sorted(by: { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }) { + let name = url.lastPathComponent + if ignoredNames.contains(name) { continue } + + var isDir: ObjCBool = false + fm.fileExists(atPath: url.path, isDirectory: &isDir) + + if isDir.boolValue { + results += collectFiles( + at: url.path, + basePath: basePath, + maxDepth: maxDepth, + currentDepth: currentDepth + 1 + ) + } else { + let relativePath = String(url.path.dropFirst(basePath.count + 1)) + let directory = (relativePath as NSString).deletingLastPathComponent + results.append(AtFileEntry( + id: relativePath, + name: name, + directory: directory, + relativePath: relativePath + )) + } + } + + return results + } +} +#endif diff --git a/Packages/Sources/RxCodeChatKit/InputBarView+Queue.swift b/Packages/Sources/RxCodeChatKit/InputBarView+Queue.swift new file mode 100644 index 0000000..f084bef --- /dev/null +++ b/Packages/Sources/RxCodeChatKit/InputBarView+Queue.swift @@ -0,0 +1,178 @@ +import SwiftUI +import TipKit +import UniformTypeIdentifiers +import RxCodeCore + +#if os(macOS) + +extension InputBarView { + // MARK: - Queued Message Previews + + var queuedMessagePreviews: some View { + VStack(spacing: 6) { + if windowState.messageQueue.count >= 2 { + queuedHeader + } + ForEach(windowState.messageQueue) { queued in + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.turn.down.right") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.top, 2) + + Text(queuedDisplayText(queued)) + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(3) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + sendQueuedNow(queued.id) + } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(chatBridge.hasPendingPlanDecision) + .help("Send now — cancels current response") + + Button { + removeQueuedMessage(queued.id) + } label: { + Image(systemName: "trash") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textSecondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Remove") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium) + .fill(ClaudeTheme.inputBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium) + .strokeBorder(ClaudeTheme.inputBorder, lineWidth: 1) + ) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 6) + } + + var queuedHeader: some View { + HStack(spacing: 8) { + Text("\(windowState.messageQueue.count) messages queued") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + + Spacer(minLength: 0) + + Button { + sendAllQueuedAsOne() + } label: { + HStack(spacing: 4) { + Image(systemName: "paperplane.fill") + .font(.system(size: ClaudeTheme.size(10), weight: .medium)) + Text("Send all as one") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + } + .foregroundStyle(ClaudeTheme.accent) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(ClaudeTheme.accentSubtle, in: Capsule()) + .overlay( + Capsule() + .strokeBorder(ClaudeTheme.accent.opacity(0.35), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(chatBridge.hasPendingPlanDecision) + .help("Combine and send all queued messages as a single turn") + } + .padding(.horizontal, 4) + } + + func removeQueuedMessage(_ id: UUID) { + withAnimation(.easeOut(duration: 0.15)) { + chatBridge.removeQueuedMessage(id: id) + } + } + + func sendQueuedNow(_ id: UUID) { + Task { await chatBridge.sendQueuedNow(id: id) } + } + + func sendAllQueuedAsOne() { + Task { await chatBridge.sendAllQueuedAsOne() } + } + + func queuedDisplayText(_ queued: QueuedMessage) -> String { + let parts = queued.attachments.map { $0.path.isEmpty ? $0.name : $0.path } + if queued.text.isEmpty { return parts.joined(separator: "\n") } + if parts.isEmpty { return queued.text } + return ([queued.text] + parts).joined(separator: "\n") + } + + // MARK: - Paste & File Import + + func processItemProviders(_ providers: [NSItemProvider]) { + for provider in providers { + if provider.hasRepresentationConforming(toTypeIdentifier: UTType.fileURL.identifier) { + loadFileURLAsAttachment(from: provider) + } else if provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier) { + loadImageDataAsAttachment(from: provider) + } + } + } + + func loadFileURLAsAttachment(from provider: NSItemProvider) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in + if let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil), + let attachment = AttachmentFactory.fromFileURL(url) { + DispatchQueue.main.async { windowState.addAttachment(attachment) } + return + } + // Inlined (not factored into loadImageDataAsAttachment) to keep `provider` within + // this nonisolated closure — passing it to a MainActor method violates Sendable. + guard provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier) else { return } + provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in + guard let data else { return } + let name = "drop-\(UUID().uuidString.prefix(8)).png" + let attachment = Attachment(type: .image, name: name, imageData: data) + DispatchQueue.main.async { windowState.addAttachment(attachment) } + } + } + } + + func loadImageDataAsAttachment(from provider: NSItemProvider) { + provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in + guard let data else { return } + let name = "drop-\(UUID().uuidString.prefix(8)).png" + let attachment = Attachment(type: .image, name: name, imageData: data) + DispatchQueue.main.async { windowState.addAttachment(attachment) } + } + } + + func handleFileImport(_ result: Result<[URL], Error>) { + guard case .success(let urls) = result else { return } + for url in urls { + if let attachment = AttachmentFactory.fromFileURL(url) { + windowState.addAttachment(attachment) + } + } + } +} +#endif diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift index 5778532..d8a3c0a 100644 --- a/Packages/Sources/RxCodeChatKit/InputBarView.swift +++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift @@ -49,11 +49,11 @@ struct InputBarView: View { self.injectedWindowState = windowState } - private var chatBridge: ChatBridge { + var chatBridge: ChatBridge { injectedChatBridge ?? environmentChatBridge! } - private var windowState: WindowState { + var windowState: WindowState { injectedWindowState ?? environmentWindowState! } @@ -655,125 +655,6 @@ struct InputBarView: View { } } - // MARK: - Queued Message Previews - - private var queuedMessagePreviews: some View { - VStack(spacing: 6) { - if windowState.messageQueue.count >= 2 { - queuedHeader - } - ForEach(windowState.messageQueue) { queued in - HStack(alignment: .top, spacing: 10) { - Image(systemName: "arrow.turn.down.right") - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - .padding(.top, 2) - - Text(queuedDisplayText(queued)) - .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(3) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - - Button { - sendQueuedNow(queued.id) - } label: { - Image(systemName: "paperplane.fill") - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .foregroundStyle(ClaudeTheme.accent) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(chatBridge.hasPendingPlanDecision) - .help("Send now — cancels current response") - - Button { - removeQueuedMessage(queued.id) - } label: { - Image(systemName: "trash") - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .foregroundStyle(ClaudeTheme.textSecondary) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Remove") - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium) - .fill(ClaudeTheme.inputBackground) - ) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium) - .strokeBorder(ClaudeTheme.inputBorder, lineWidth: 1) - ) - .frame(maxWidth: .infinity) - } - } - .padding(.horizontal, 8) - .padding(.bottom, 6) - } - - private var queuedHeader: some View { - HStack(spacing: 8) { - Text("\(windowState.messageQueue.count) messages queued") - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - - Spacer(minLength: 0) - - Button { - sendAllQueuedAsOne() - } label: { - HStack(spacing: 4) { - Image(systemName: "paperplane.fill") - .font(.system(size: ClaudeTheme.size(10), weight: .medium)) - Text("Send all as one") - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - } - .foregroundStyle(ClaudeTheme.accent) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(ClaudeTheme.accentSubtle, in: Capsule()) - .overlay( - Capsule() - .strokeBorder(ClaudeTheme.accent.opacity(0.35), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(chatBridge.hasPendingPlanDecision) - .help("Combine and send all queued messages as a single turn") - } - .padding(.horizontal, 4) - } - - private func removeQueuedMessage(_ id: UUID) { - withAnimation(.easeOut(duration: 0.15)) { - chatBridge.removeQueuedMessage(id: id) - } - } - - private func sendQueuedNow(_ id: UUID) { - Task { await chatBridge.sendQueuedNow(id: id) } - } - - private func sendAllQueuedAsOne() { - Task { await chatBridge.sendAllQueuedAsOne() } - } - - private func queuedDisplayText(_ queued: QueuedMessage) -> String { - let parts = queued.attachments.map { $0.path.isEmpty ? $0.name : $0.path } - if queued.text.isEmpty { return parts.joined(separator: "\n") } - if parts.isEmpty { return queued.text } - return ([queued.text] + parts).joined(separator: "\n") - } - // MARK: - Attachment Previews private var attachmentPreviews: some View { @@ -847,56 +728,6 @@ struct InputBarView: View { private func handleShiftReturnKey() { windowState.inputText.append("\n") } - - // MARK: - Paste & File Import - - private func processItemProviders(_ providers: [NSItemProvider]) { - for provider in providers { - if provider.hasRepresentationConforming(toTypeIdentifier: UTType.fileURL.identifier) { - loadFileURLAsAttachment(from: provider) - } else if provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier) { - loadImageDataAsAttachment(from: provider) - } - } - } - - private func loadFileURLAsAttachment(from provider: NSItemProvider) { - provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, _ in - if let data = item as? Data, - let url = URL(dataRepresentation: data, relativeTo: nil), - let attachment = AttachmentFactory.fromFileURL(url) { - DispatchQueue.main.async { windowState.addAttachment(attachment) } - return - } - // Inlined (not factored into loadImageDataAsAttachment) to keep `provider` within - // this nonisolated closure — passing it to a MainActor method violates Sendable. - guard provider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier) else { return } - provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in - guard let data else { return } - let name = "drop-\(UUID().uuidString.prefix(8)).png" - let attachment = Attachment(type: .image, name: name, imageData: data) - DispatchQueue.main.async { windowState.addAttachment(attachment) } - } - } - } - - private func loadImageDataAsAttachment(from provider: NSItemProvider) { - provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in - guard let data else { return } - let name = "drop-\(UUID().uuidString.prefix(8)).png" - let attachment = Attachment(type: .image, name: name, imageData: data) - DispatchQueue.main.async { windowState.addAttachment(attachment) } - } - } - - private func handleFileImport(_ result: Result<[URL], Error>) { - guard case .success(let urls) = result else { return } - for url in urls { - if let attachment = AttachmentFactory.fromFileURL(url) { - windowState.addAttachment(attachment) - } - } - } } // IMETextView's NSScrollView doesn't surface intrinsic height, so a hidden Text at the same diff --git a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift index 04953c7..590fc11 100644 --- a/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift +++ b/Packages/Sources/RxCodeChatKit/SlashCommandBar.swift @@ -753,245 +753,4 @@ struct UsagePopoverView: View { } } -// MARK: - @ File Search Popup - -struct AtFilePopup: View { - let entries: [AtFileEntry] - let onSelect: (String) -> Void - @Binding var selectedIndex: Int - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header - HStack { - Image(systemName: "doc.text.magnifyingglass") - .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(ClaudeTheme.textTertiary) - Text("File Search", bundle: .module) - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - Spacer() - Text("\(entries.count)") - .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - - Divider() - .foregroundStyle(ClaudeTheme.borderSubtle) - - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in - fileRowButton(entry, isSelected: index == selectedIndex) - .id(index) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .onChange(of: selectedIndex) { _, newValue in - withAnimation(.easeOut(duration: 0.1)) { - proxy.scrollTo(newValue, anchor: .center) - } - } - } - } - .frame(height: 320) - .background(ClaudeTheme.surfaceElevated) - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium)) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusMedium) - .strokeBorder(ClaudeTheme.border, lineWidth: 1) - ) - .shadow(color: ClaudeTheme.shadowColor, radius: 12, y: -4) - } - - @ViewBuilder - private func fileRowButton(_ entry: AtFileEntry, isSelected: Bool) -> some View { - Button { - onSelect(entry.relativePath) - } label: { - HStack(spacing: 10) { - Image(systemName: entry.icon) - .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(isSelected ? ClaudeTheme.accent : entry.iconColor) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - .foregroundStyle(isSelected ? ClaudeTheme.accent : ClaudeTheme.textPrimary) - - if !entry.directory.isEmpty { - Text(entry.directory) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - } - } - - Spacer() - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(isSelected ? ClaudeTheme.accentSubtle : Color.clear) - } - -} - -// MARK: - AtFileEntry - -struct AtFileEntry: Identifiable { - let id: String // relativePath - let name: String // file name - let directory: String // parent directory path - let relativePath: String - - var icon: String { - let ext = (name as NSString).pathExtension.lowercased() - switch ext { - case "swift": return "swift" - case "js", "jsx", "ts", "tsx": return "chevron.left.forwardslash.chevron.right" - case "json": return "curlybraces" - case "md", "txt": return "doc.text" - case "png", "jpg", "jpeg", "svg", "pdf": return "photo" - case "css", "scss": return "paintbrush" - case "html": return "globe" - case "yaml", "yml", "toml": return "gearshape" - default: return "doc" - } - } - - var iconColor: Color { - let ext = (name as NSString).pathExtension.lowercased() - switch ext { - case "swift": return .orange - case "js", "jsx": return .yellow - case "ts", "tsx": return .blue - case "json": return ClaudeTheme.statusSuccess - case "css", "scss": return .pink - case "html": return ClaudeTheme.statusError - case "png", "jpg", "jpeg", "svg", "pdf": return .purple - default: return ClaudeTheme.textTertiary - } - } -} - -// MARK: - AtFileSearch - -enum AtFileSearch { - private nonisolated static let ignoredNames: Set = [ - ".git", ".build", ".swiftpm", "DerivedData", - "node_modules", ".DS_Store", "Pods", - "xcuserdata", ".xcodeproj", ".xcworkspace", - ] - - // File list cache keyed by project path. Populated once per project on first use - // or proactively via prefetch(projectPath:). Filtering against the cached list is - // cheap (in-memory), so typing after the first @ character is fast. - private static var fileListCache: [String: [AtFileEntry]] = [:] - private static var prefetchingPaths: Set = [] - - /// Pre-warms the file cache for a project in the background. - /// Call when a project is selected so the cache is ready when the user types @. - static func prefetch(projectPath: String) { - guard fileListCache[projectPath] == nil, !prefetchingPaths.contains(projectPath) else { return } - prefetchingPaths.insert(projectPath) - Task { - let files = await Task.detached(priority: .utility) { - AtFileSearch.collectFiles(at: projectPath, basePath: projectPath, maxDepth: 6) - }.value - fileListCache[projectPath] = files - prefetchingPaths.remove(projectPath) - } - } - - /// Invalidates the cached file list for a project (e.g. after file-tree changes). - static func invalidate(for projectPath: String) { - fileListCache.removeValue(forKey: projectPath) - } - - static func search(query: String, projectPath: String, maxResults: Int = 20) -> [AtFileEntry] { - // Use the cached file list; fall back to a synchronous scan only if the - // prefetch hasn't finished yet (should be rare after the first project open). - let allFiles: [AtFileEntry] - if let cached = fileListCache[projectPath] { - allFiles = cached - } else { - allFiles = collectFiles(at: projectPath, basePath: projectPath, maxDepth: 6) - fileListCache[projectPath] = allFiles - } - - let q = query.lowercased() - guard !q.isEmpty else { - return Array(allFiles.prefix(maxResults)) - } - - // Filename match takes priority, path match is secondary - var nameMatches: [AtFileEntry] = [] - var pathMatches: [AtFileEntry] = [] - - for entry in allFiles { - if entry.name.lowercased().contains(q) { - nameMatches.append(entry) - } else if entry.relativePath.lowercased().contains(q) { - pathMatches.append(entry) - } - } - - let combined = nameMatches + pathMatches - return Array(combined.prefix(maxResults)) - } - - private nonisolated static func collectFiles( - at path: String, - basePath: String, - maxDepth: Int, - currentDepth: Int = 0 - ) -> [AtFileEntry] { - guard currentDepth <= maxDepth else { return [] } - - let fm = FileManager.default - guard let contents = try? fm.contentsOfDirectory( - at: URL(fileURLWithPath: path), - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { return [] } - - var results: [AtFileEntry] = [] - - for url in contents.sorted(by: { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }) { - let name = url.lastPathComponent - if ignoredNames.contains(name) { continue } - - var isDir: ObjCBool = false - fm.fileExists(atPath: url.path, isDirectory: &isDir) - - if isDir.boolValue { - results += collectFiles( - at: url.path, - basePath: basePath, - maxDepth: maxDepth, - currentDepth: currentDepth + 1 - ) - } else { - let relativePath = String(url.path.dropFirst(basePath.count + 1)) - let directory = (relativePath as NSString).deletingLastPathComponent - results.append(AtFileEntry( - id: relativePath, - name: name, - directory: directory, - relativePath: relativePath - )) - } - } - - return results - } -} #endif diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift new file mode 100644 index 0000000..a5db0be --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+RemoteManagement.swift @@ -0,0 +1,701 @@ +import Foundation +import RxCodeCore + +// MARK: - Skills / ACP / MCP remote management + +/// Mobile asks the desktop for the skill marketplace catalog. `forceRefresh` +/// bypasses the desktop's 5-minute marketplace cache. +public struct SkillCatalogRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let forceRefresh: Bool + + public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { + self.clientRequestID = clientRequestID + self.forceRefresh = forceRefresh + } +} + +/// One marketplace plugin flattened from the desktop's `MarketplacePlugin` +/// plus its current install state. `id` mirrors `MarketplacePlugin.id`. +public struct MobileSkillPlugin: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let name: String + public let summary: String + public let author: String + public let category: String + public let categoryLabel: String + public let marketplace: String + public let marketplaceLabel: String + public let homepage: String + public let isInstalled: Bool + + public init( + id: String, + name: String, + summary: String, + author: String, + category: String, + categoryLabel: String, + marketplace: String, + marketplaceLabel: String, + homepage: String, + isInstalled: Bool + ) { + self.id = id + self.name = name + self.summary = summary + self.author = author + self.category = category + self.categoryLabel = categoryLabel + self.marketplace = marketplace + self.marketplaceLabel = marketplaceLabel + self.homepage = homepage + self.isInstalled = isInstalled + } +} + +public struct MobileSkillSource: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} + +public struct SkillCatalogResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources + } +} + +/// Mobile asks the desktop to install or remove a marketplace skill. `pluginID` +/// is the catalog id; the desktop re-resolves the authoritative plugin from its +/// own freshly-fetched catalog. +public struct SkillMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case install + case uninstall + } + + public let clientRequestID: UUID + public let operation: Operation + public let pluginID: String + + public init(clientRequestID: UUID = UUID(), operation: Operation, pluginID: String) { + self.clientRequestID = clientRequestID + self.operation = operation + self.pluginID = pluginID + } +} + +public struct SkillMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: SkillMutationRequestPayload.Operation + public let pluginID: String + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + operation: SkillMutationRequestPayload.Operation, + pluginID: String, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.pluginID = pluginID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources + } +} + +public struct SkillSourceMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case add + case remove + } + + public let clientRequestID: UUID + public let operation: Operation + public let sourceID: String? + public let gitURL: String? + public let ref: String? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + sourceID: String? = nil, + gitURL: String? = nil, + ref: String? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.sourceID = sourceID + self.gitURL = gitURL + self.ref = ref + } +} + +public struct SkillSourceMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: SkillSourceMutationRequestPayload.Operation + public let sourceID: String? + public let ok: Bool + public let errorMessage: String? + public let plugins: [MobileSkillPlugin] + public let sources: [MobileSkillSource] + + public init( + clientRequestID: UUID, + operation: SkillSourceMutationRequestPayload.Operation, + sourceID: String? = nil, + ok: Bool, + errorMessage: String? = nil, + plugins: [MobileSkillPlugin] = [], + sources: [MobileSkillSource] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.sourceID = sourceID + self.ok = ok + self.errorMessage = errorMessage + self.plugins = plugins + self.sources = sources + } +} + +/// Mobile asks the desktop for the ACP agent registry plus installed clients. +public struct ACPRegistryRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let forceRefresh: Bool + + public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { + self.clientRequestID = clientRequestID + self.forceRefresh = forceRefresh + } +} + +/// A registry agent flattened from the desktop's `ACPRegistryAgent`, plus +/// whether a matching client is already installed locally. +public struct MobileACPRegistryAgent: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let name: String + public let version: String + public let summary: String + public let authors: [String] + public let license: String? + public let website: String? + public let iconURL: String? + public let isInstalled: Bool + public let hasBinary: Bool + public let hasNpx: Bool + public let hasUvx: Bool + + public init( + id: String, + name: String, + version: String, + summary: String, + authors: [String] = [], + license: String? = nil, + website: String? = nil, + iconURL: String? = nil, + isInstalled: Bool, + hasBinary: Bool, + hasNpx: Bool, + hasUvx: Bool + ) { + self.id = id + self.name = name + self.version = version + self.summary = summary + self.authors = authors + self.license = license + self.website = website + self.iconURL = iconURL + self.isInstalled = isInstalled + self.hasBinary = hasBinary + self.hasNpx = hasNpx + self.hasUvx = hasUvx + } +} + +/// An installed ACP client mirrored from the desktop's `ACPClientSpec`. +public struct MobileACPClient: Codable, Sendable, Identifiable, Equatable { + public let id: String + public let registryId: String? + public let displayName: String + public let enabled: Bool + public let launchKind: String + public let modelCount: Int + public let iconURL: String? + + public init( + id: String, + registryId: String? = nil, + displayName: String, + enabled: Bool, + launchKind: String, + modelCount: Int, + iconURL: String? = nil + ) { + self.id = id + self.registryId = registryId + self.displayName = displayName + self.enabled = enabled + self.launchKind = launchKind + self.modelCount = modelCount + self.iconURL = iconURL + } +} + +public struct ACPRegistryResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let registryAgents: [MobileACPRegistryAgent] + public let installedClients: [MobileACPClient] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + registryAgents: [MobileACPRegistryAgent] = [], + installedClients: [MobileACPClient] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.registryAgents = registryAgents + self.installedClients = installedClients + } +} + +/// Mobile asks the desktop to install an ACP agent from the registry, remove an +/// installed client, or toggle a client's enabled flag. +public struct ACPMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case install + case uninstall + case setEnabled + } + + public let clientRequestID: UUID + public let operation: Operation + public let registryAgentID: String? + public let clientID: String? + public let enabled: Bool? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + registryAgentID: String? = nil, + clientID: String? = nil, + enabled: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.registryAgentID = registryAgentID + self.clientID = clientID + self.enabled = enabled + } +} + +public struct ACPMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: ACPMutationRequestPayload.Operation + public let ok: Bool + public let errorMessage: String? + public let registryAgents: [MobileACPRegistryAgent] + public let installedClients: [MobileACPClient] + + public init( + clientRequestID: UUID, + operation: ACPMutationRequestPayload.Operation, + ok: Bool, + errorMessage: String? = nil, + registryAgents: [MobileACPRegistryAgent] = [], + installedClients: [MobileACPClient] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.ok = ok + self.errorMessage = errorMessage + self.registryAgents = registryAgents + self.installedClients = installedClients + } +} + +/// Mobile asks the desktop for the configured global MCP servers. +public struct MCPConfigRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + + public init(clientRequestID: UUID = UUID()) { + self.clientRequestID = clientRequestID + } +} + +/// A plain key/value pair for MCP environment variables and headers. The +/// desktop's `MCPKeyValue` carries a non-Codable UUID, so the wire uses this. +public struct MobileMCPKeyValue: Codable, Sendable, Equatable, Hashable { + public let key: String + public let value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } +} + +/// One global MCP server flattened from the desktop's `MCPServerRecord`. +public struct MobileMCPServer: Codable, Sendable, Identifiable, Equatable { + public var id: String { name } + + public let name: String + public let transport: String + public let url: String? + public let command: String? + public let args: [String] + public let env: [MobileMCPKeyValue] + public let headers: [MobileMCPKeyValue] + public let isGloballyEnabled: Bool + public let endpoint: String + + public init( + name: String, + transport: String, + url: String? = nil, + command: String? = nil, + args: [String] = [], + env: [MobileMCPKeyValue] = [], + headers: [MobileMCPKeyValue] = [], + isGloballyEnabled: Bool, + endpoint: String + ) { + self.name = name + self.transport = transport + self.url = url + self.command = command + self.args = args + self.env = env + self.headers = headers + self.isGloballyEnabled = isGloballyEnabled + self.endpoint = endpoint + } +} + +public struct MCPConfigResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let ok: Bool + public let errorMessage: String? + public let servers: [MobileMCPServer] + + public init( + clientRequestID: UUID, + ok: Bool, + errorMessage: String? = nil, + servers: [MobileMCPServer] = [] + ) { + self.clientRequestID = clientRequestID + self.ok = ok + self.errorMessage = errorMessage + self.servers = servers + } +} + +/// Mobile asks the desktop to add/upsert, remove, or toggle a global MCP server. +public struct MCPMutationRequestPayload: Codable, Sendable { + public enum Operation: String, Codable, Sendable { + case add + case remove + case setEnabled + } + + public let clientRequestID: UUID + public let operation: Operation + public let serverName: String + public let server: MobileMCPServer? + public let enabled: Bool? + + public init( + clientRequestID: UUID = UUID(), + operation: Operation, + serverName: String, + server: MobileMCPServer? = nil, + enabled: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.serverName = serverName + self.server = server + self.enabled = enabled + } +} + +public struct MCPMutationResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let operation: MCPMutationRequestPayload.Operation + public let serverName: String + public let ok: Bool + public let errorMessage: String? + public let servers: [MobileMCPServer] + + public init( + clientRequestID: UUID, + operation: MCPMutationRequestPayload.Operation, + serverName: String, + ok: Bool, + errorMessage: String? = nil, + servers: [MobileMCPServer] = [] + ) { + self.clientRequestID = clientRequestID + self.operation = operation + self.serverName = serverName + self.ok = ok + self.errorMessage = errorMessage + self.servers = servers + } +} + +public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { + public var id: String { "\(projectId.uuidString)::\(branch)" } + + public let projectId: UUID + public let branch: String + public let briefing: String + public let updatedAt: Date + + public init(projectId: UUID, branch: String, briefing: String, updatedAt: Date) { + self.projectId = projectId + self.branch = branch + self.briefing = briefing + self.updatedAt = updatedAt + } +} + +public struct MobileThreadSummary: Codable, Sendable, Identifiable, Equatable { + public var id: String { sessionId } + + public let sessionId: String + public let projectId: UUID + public let branch: String + public let title: String + public let summary: String + public let updatedAt: Date + + public init( + sessionId: String, + projectId: UUID, + branch: String, + title: String, + summary: String, + updatedAt: Date + ) { + self.sessionId = sessionId + self.projectId = projectId + self.branch = branch + self.title = title + self.summary = summary + self.updatedAt = updatedAt + } +} + +/// One selectable summarization provider, mirrored from the desktop's +/// `SummarizationProvider` enum. Sent as data rather than the enum itself +/// because the enum — and its hardware-dependent availability (Apple +/// Foundation Model) — lives in the desktop target. +public struct SummarizationProviderOption: Codable, Sendable, Equatable, Identifiable { + /// Raw value of the desktop's `SummarizationProvider` case. + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } +} + +public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { + public let selectedAgentProvider: AgentProvider + public let selectedModel: String + public let selectedACPClientId: String + public let selectedEffort: String + public let permissionMode: PermissionMode + public let summarizationProvider: String + public let summarizationProviderDisplayName: String + public let openAISummarizationEndpoint: String + public let openAISummarizationModel: String + public let notificationsEnabled: Bool + public let focusMode: Bool + public let autoArchiveEnabled: Bool + public let archiveRetentionDays: Int + public let autoPreviewSettings: AttachmentAutoPreviewSettings + public let availableEfforts: [String] + /// All agent models discovered on the desktop, flattened across providers + /// and ACP clients. Lets mobile show a model picker without re-deriving the + /// list itself. Optional for backward compatibility with older desktops. + public let availableModels: [AgentModel]? + /// Model picker sections, mirroring the desktop layout — Claude Code, + /// Codex, and one section per enabled ACP client. Lets mobile reproduce the + /// desktop's per-ACP-client grouping instead of collapsing every ACP client + /// into a single bucket. Optional for backward compatibility with older + /// desktops, which sent only the flattened `availableModels` list. + public let modelSections: [AgentModelSection]? + /// Summarization providers the desktop currently offers, so mobile can + /// render a provider picker without re-deriving hardware availability. + /// Optional for backward compatibility with older desktops. + public let availableSummarizationProviders: [SummarizationProviderOption]? + /// Model identifiers fetched from the OpenAI-compatible summarization + /// endpoint, so mobile can render a model picker. Empty/`nil` when the + /// desktop hasn't fetched them yet (e.g. no API key configured). + public let openAISummarizationModels: [String]? + + public init( + selectedAgentProvider: AgentProvider, + selectedModel: String, + selectedACPClientId: String, + selectedEffort: String, + permissionMode: PermissionMode, + summarizationProvider: String, + summarizationProviderDisplayName: String, + openAISummarizationEndpoint: String, + openAISummarizationModel: String, + notificationsEnabled: Bool, + focusMode: Bool, + autoArchiveEnabled: Bool, + archiveRetentionDays: Int, + autoPreviewSettings: AttachmentAutoPreviewSettings, + availableEfforts: [String], + availableModels: [AgentModel]? = nil, + modelSections: [AgentModelSection]? = nil, + availableSummarizationProviders: [SummarizationProviderOption]? = nil, + openAISummarizationModels: [String]? = nil + ) { + self.selectedAgentProvider = selectedAgentProvider + self.selectedModel = selectedModel + self.selectedACPClientId = selectedACPClientId + self.selectedEffort = selectedEffort + self.permissionMode = permissionMode + self.summarizationProvider = summarizationProvider + self.summarizationProviderDisplayName = summarizationProviderDisplayName + self.openAISummarizationEndpoint = openAISummarizationEndpoint + self.openAISummarizationModel = openAISummarizationModel + self.notificationsEnabled = notificationsEnabled + self.focusMode = focusMode + self.autoArchiveEnabled = autoArchiveEnabled + self.archiveRetentionDays = archiveRetentionDays + self.autoPreviewSettings = autoPreviewSettings + self.availableEfforts = availableEfforts + self.availableModels = availableModels + self.modelSections = modelSections + self.availableSummarizationProviders = availableSummarizationProviders + self.openAISummarizationModels = openAISummarizationModels + } + + private enum CodingKeys: String, CodingKey { + case selectedAgentProvider, selectedModel, selectedACPClientId, selectedEffort + case permissionMode, summarizationProvider, summarizationProviderDisplayName + case openAISummarizationEndpoint, openAISummarizationModel + case notificationsEnabled, focusMode, autoArchiveEnabled, archiveRetentionDays + case autoPreviewSettings, availableEfforts, availableModels, modelSections + case availableSummarizationProviders, openAISummarizationModels + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + selectedAgentProvider = try c.decode(AgentProvider.self, forKey: .selectedAgentProvider) + selectedModel = try c.decode(String.self, forKey: .selectedModel) + selectedACPClientId = try c.decode(String.self, forKey: .selectedACPClientId) + selectedEffort = try c.decode(String.self, forKey: .selectedEffort) + permissionMode = try c.decode(PermissionMode.self, forKey: .permissionMode) + summarizationProvider = try c.decode(String.self, forKey: .summarizationProvider) + summarizationProviderDisplayName = try c.decode(String.self, forKey: .summarizationProviderDisplayName) + openAISummarizationEndpoint = try c.decode(String.self, forKey: .openAISummarizationEndpoint) + openAISummarizationModel = try c.decode(String.self, forKey: .openAISummarizationModel) + notificationsEnabled = try c.decode(Bool.self, forKey: .notificationsEnabled) + focusMode = try c.decode(Bool.self, forKey: .focusMode) + autoArchiveEnabled = try c.decode(Bool.self, forKey: .autoArchiveEnabled) + archiveRetentionDays = try c.decode(Int.self, forKey: .archiveRetentionDays) + autoPreviewSettings = try c.decode(AttachmentAutoPreviewSettings.self, forKey: .autoPreviewSettings) + availableEfforts = try c.decode([String].self, forKey: .availableEfforts) + availableModels = try c.decodeIfPresent([AgentModel].self, forKey: .availableModels) + modelSections = try c.decodeIfPresent([AgentModelSection].self, forKey: .modelSections) + availableSummarizationProviders = try c.decodeIfPresent( + [SummarizationProviderOption].self, forKey: .availableSummarizationProviders + ) + openAISummarizationModels = try c.decodeIfPresent( + [String].self, forKey: .openAISummarizationModels + ) + } +} + +public struct MobileSettingsUpdatePayload: Codable, Sendable { + public let selectedAgentProvider: AgentProvider? + public let selectedModel: String? + public let selectedACPClientId: String? + public let selectedEffort: String? + public let permissionMode: PermissionMode? + /// Raw value of a `SummarizationProvider` case the user picked on mobile. + public let summarizationProvider: String? + /// Model identifier picked on mobile for the OpenAI-compatible endpoint. + public let openAISummarizationModel: String? + public let notificationsEnabled: Bool? + public let focusMode: Bool? + public let autoArchiveEnabled: Bool? + public let archiveRetentionDays: Int? + public let autoPreviewSettings: AttachmentAutoPreviewSettings? + + public init( + selectedAgentProvider: AgentProvider? = nil, + selectedModel: String? = nil, + selectedACPClientId: String? = nil, + selectedEffort: String? = nil, + permissionMode: PermissionMode? = nil, + summarizationProvider: String? = nil, + openAISummarizationModel: String? = nil, + notificationsEnabled: Bool? = nil, + focusMode: Bool? = nil, + autoArchiveEnabled: Bool? = nil, + archiveRetentionDays: Int? = nil, + autoPreviewSettings: AttachmentAutoPreviewSettings? = nil + ) { + self.selectedAgentProvider = selectedAgentProvider + self.selectedModel = selectedModel + self.selectedACPClientId = selectedACPClientId + self.selectedEffort = selectedEffort + self.permissionMode = permissionMode + self.summarizationProvider = summarizationProvider + self.openAISummarizationModel = openAISummarizationModel + self.notificationsEnabled = notificationsEnabled + self.focusMode = focusMode + self.autoArchiveEnabled = autoArchiveEnabled + self.archiveRetentionDays = archiveRetentionDays + self.autoPreviewSettings = autoPreviewSettings + } +} + diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift new file mode 100644 index 0000000..185ac1d --- /dev/null +++ b/Packages/Sources/RxCodeSync/Protocol/Payload+Sessions.swift @@ -0,0 +1,642 @@ +import Foundation +import RxCodeCore + +// MARK: - Session & thread payloads + +public struct SessionProgressSnapshot: Codable, Sendable, Equatable { + public let done: Int + public let total: Int + public let inProgress: Bool + + public init(done: Int, total: Int, inProgress: Bool) { + self.done = done + self.total = total + self.inProgress = inProgress + } +} + +public enum SessionAttentionKind: String, Codable, Sendable, Equatable { + case permission + case question +} + +public struct SessionSummary: Codable, Sendable, Identifiable { + public let id: String + public let projectId: UUID + public let title: String + public let updatedAt: Date + public let isPinned: Bool + public let isArchived: Bool + public let isStreaming: Bool + public let attention: SessionAttentionKind? + public let progress: SessionProgressSnapshot? + /// Latest todo items for this session. Claude sessions can still derive + /// these from `TodoWrite` messages, but Codex plan updates are persisted as + /// snapshots instead of message tool calls, so the mobile app needs the + /// desktop-owned item list. + public let todos: [TodoItem]? + /// Messages waiting to be sent once the active turn finishes. Mirrored from + /// the desktop's per-session queue (threadStore). `nil` when the summary + /// comes from an older desktop that doesn't know about queue sync. + public let queuedMessages: [QueuedUserMessage]? + /// Whether the session's stream finished while the user wasn't viewing it + /// and it hasn't been opened since. Mirrors the desktop's + /// `hasUncheckedCompletion` so mobile can show the same green + /// "finished, unread" indicator. Defaults to `false` for summaries from + /// an older desktop that predates this field. + public let hasUncheckedCompletion: Bool + + public init( + id: String, + projectId: UUID, + title: String, + updatedAt: Date, + isPinned: Bool, + isArchived: Bool, + isStreaming: Bool = false, + attention: SessionAttentionKind? = nil, + progress: SessionProgressSnapshot? = nil, + todos: [TodoItem]? = nil, + queuedMessages: [QueuedUserMessage]? = nil, + hasUncheckedCompletion: Bool = false + ) { + self.id = id + self.projectId = projectId + self.title = title + self.updatedAt = updatedAt + self.isPinned = isPinned + self.isArchived = isArchived + self.isStreaming = isStreaming + self.attention = attention + self.progress = progress + self.todos = todos + self.queuedMessages = queuedMessages + self.hasUncheckedCompletion = hasUncheckedCompletion + } + + private enum CodingKeys: String, CodingKey { + case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + projectId = try container.decode(UUID.self, forKey: .projectId) + title = try container.decode(String.self, forKey: .title) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false + isStreaming = try container.decodeIfPresent(Bool.self, forKey: .isStreaming) ?? false + attention = try container.decodeIfPresent(SessionAttentionKind.self, forKey: .attention) + progress = try container.decodeIfPresent(SessionProgressSnapshot.self, forKey: .progress) + todos = try container.decodeIfPresent([TodoItem].self, forKey: .todos) + queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) + hasUncheckedCompletion = try container.decodeIfPresent(Bool.self, forKey: .hasUncheckedCompletion) ?? false + } +} + +public struct SessionUpdatePayload: Codable, Sendable { + public enum Kind: String, Codable, Sendable { + case messageAppended + case messageUpdated + case streamingStarted + case streamingFinished + case statusChanged + } + public let sessionID: String + public let kind: Kind + public let message: ChatMessage? + public let isStreaming: Bool? + /// Whether the agent is currently producing reasoning/thinking tokens (as + /// opposed to output text). Drives the mobile streaming indicator's + /// "Thinking…" label, mirroring the desktop. Optional for backward + /// compatibility with desktops that predate thinking sync. + public let isThinking: Bool? + public let summary: SessionSummary? + public let previousSessionID: String? + + public init( + sessionID: String, + kind: Kind, + message: ChatMessage? = nil, + isStreaming: Bool? = nil, + isThinking: Bool? = nil, + summary: SessionSummary? = nil, + previousSessionID: String? = nil + ) { + self.sessionID = sessionID + self.kind = kind + self.message = message + self.isStreaming = isStreaming + self.isThinking = isThinking + self.summary = summary + self.previousSessionID = previousSessionID + } +} + +public struct SubscribeSessionPayload: Codable, Sendable { + public let sessionID: String? + public init(sessionID: String?) { self.sessionID = sessionID } +} + +public struct UserMessagePayload: Codable, Sendable { + public let clientMessageID: UUID + public let sessionID: String + public let text: String + public init(clientMessageID: UUID = UUID(), sessionID: String, text: String) { + self.clientMessageID = clientMessageID + self.sessionID = sessionID + self.text = text + } +} + +public struct CancelStreamPayload: Codable, Sendable { + public let sessionID: String + public init(sessionID: String) { + self.sessionID = sessionID + } +} + +/// A user message that's waiting for the active turn to finish before being +/// sent to the agent. Mirrored to mobile via `SessionSummary.queuedMessages`. +public struct QueuedUserMessage: Codable, Sendable, Identifiable, Equatable { + public let id: UUID + public let text: String + public init(id: UUID, text: String) { + self.id = id + self.text = text + } +} + +/// Asks the desktop to drop the queued message from threadStore. Used by the +/// mobile UI when the user swipes a queued row away. The desktop never tries +/// to send the message after this point. +public struct RemoveQueuedMessagePayload: Codable, Sendable { + public let sessionID: String + public let queuedMessageID: UUID + public init(sessionID: String, queuedMessageID: UUID) { + self.sessionID = sessionID + self.queuedMessageID = queuedMessageID + } +} + +public struct NewSessionRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let projectID: UUID + public let initialText: String? + /// Per-thread agent configuration captured in the mobile new-thread sheet. + /// All optional for wire-compatibility with older builds — synthesized + /// `Codable` decodes missing keys as `nil`, in which case the desktop falls + /// back to its global defaults. + public let selectedAgentProvider: AgentProvider? + public let selectedModel: String? + public let selectedACPClientId: String? + public let selectedEffort: String? + public let permissionMode: PermissionMode? + /// When `true`, the desktop starts the thread in plan mode + /// (CLI `--permission-mode plan`). + public let planMode: Bool? + public init( + clientRequestID: UUID = UUID(), + projectID: UUID, + initialText: String? = nil, + selectedAgentProvider: AgentProvider? = nil, + selectedModel: String? = nil, + selectedACPClientId: String? = nil, + selectedEffort: String? = nil, + permissionMode: PermissionMode? = nil, + planMode: Bool? = nil + ) { + self.clientRequestID = clientRequestID + self.projectID = projectID + self.initialText = initialText + self.selectedAgentProvider = selectedAgentProvider + self.selectedModel = selectedModel + self.selectedACPClientId = selectedACPClientId + self.selectedEffort = selectedEffort + self.permissionMode = permissionMode + self.planMode = planMode + } +} + +/// Mobile-initiated lifecycle action on an existing thread: rename, archive, +/// unarchive, or delete. The desktop applies the action against its +/// authoritative session store and broadcasts a fresh snapshot so every paired +/// device reconciles. Fire-and-forget — there is no dedicated result payload. +public struct ThreadActionRequestPayload: Codable, Sendable { + public enum Action: String, Codable, Sendable { + case rename + case archive + case unarchive + case delete + } + + public let clientRequestID: UUID + public let sessionID: String + public let action: Action + /// New title, required only when `action == .rename`. + public let newTitle: String? + + public init( + clientRequestID: UUID = UUID(), + sessionID: String, + action: Action, + newTitle: String? = nil + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.action = action + self.newTitle = newTitle + } +} + +/// Mobile-initiated request for an older page of a thread's messages. Mobile +/// holds only the most recent window (see `SnapshotPayload.activeSessionMessages`) +/// and pages backwards as the user scrolls up. The desktop replies with a +/// `MoreMessagesPayload` carrying the messages strictly older than +/// `beforeMessageID`. +public struct LoadMoreMessagesRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// The oldest message the requester currently holds. The desktop returns + /// messages that come before this one in the thread. + public let beforeMessageID: UUID + /// How many older messages to return at most. + public let limit: Int + + public init( + clientRequestID: UUID = UUID(), + sessionID: String, + beforeMessageID: UUID, + limit: Int + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.beforeMessageID = beforeMessageID + self.limit = limit + } +} + +/// Desktop reply to a `LoadMoreMessagesRequestPayload`: one older page of a +/// thread's messages, to be prepended to the requester's local window. +public struct MoreMessagesPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// Older messages in chronological order (oldest first). + public let messages: [ChatMessage] + /// Whether messages older than this page still remain. + public let hasMore: Bool + + public init( + clientRequestID: UUID, + sessionID: String, + messages: [ChatMessage], + hasMore: Bool + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.messages = messages + self.hasMore = hasMore + } +} + +/// Mobile-initiated search across all threads and projects. The desktop is +/// the authoritative source of session content, so the heavy lifting (semantic +/// matching, title/project lookups) lives there; mobile just renders results. +public struct SearchRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let query: String + public let limit: Int + public init(clientRequestID: UUID = UUID(), query: String, limit: Int = 25) { + self.clientRequestID = clientRequestID + self.query = query + self.limit = limit + } +} + +public struct SearchHit: Codable, Sendable, Identifiable, Equatable { + public let sessionID: String + public let projectID: UUID + public let title: String + public let snippet: String + public let updatedAt: Date + public let score: Float + public var id: String { sessionID } + public init( + sessionID: String, + projectID: UUID, + title: String, + snippet: String, + updatedAt: Date, + score: Float + ) { + self.sessionID = sessionID + self.projectID = projectID + self.title = title + self.snippet = snippet + self.updatedAt = updatedAt + self.score = score + } +} + +public struct SearchResultsPayload: Codable, Sendable { + public let clientRequestID: UUID + public let query: String + public let projectIDs: [UUID] + public let threadHits: [SearchHit] + public init(clientRequestID: UUID, query: String, projectIDs: [UUID], threadHits: [SearchHit]) { + self.clientRequestID = clientRequestID + self.query = query + self.projectIDs = projectIDs + self.threadHits = threadHits + } +} + +// MARK: - Thread changes + +/// Mobile-initiated request for the change overview of a thread: every file +/// edited in the thread session plus the project's uncommitted git changes. +/// The desktop is the authoritative source for both (SwiftData edit history and +/// the working tree), so it builds the whole `ThreadChangesResultPayload`. +public struct ThreadChangesRequestPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + + public init(clientRequestID: UUID = UUID(), sessionID: String) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + } +} + +/// One old/new replacement pair. Wire form of `PreviewFile.EditHunk`, which is +/// not itself `Codable`. +public struct SyncEditHunk: Codable, Sendable, Equatable { + public let oldString: String + public let newString: String + + public init(oldString: String, newString: String) { + self.oldString = oldString + self.newString = newString + } +} + +/// Aggregated edits to a single file across a whole thread session. Wire form +/// of `FileEditSummary`. +public struct SyncFileEdit: Codable, Sendable, Identifiable { + public var id: String { path } + public let path: String + public let name: String + /// True if any contributing tool was Write — old content was overwritten. + public let containsWrite: Bool + public let hunks: [SyncEditHunk] + + public init(path: String, name: String, containsWrite: Bool, hunks: [SyncEditHunk]) { + self.path = path + self.name = name + self.containsWrite = containsWrite + self.hunks = hunks + } +} + +/// Which side of the working tree a git change lives on. +public enum SyncGitChangeKind: String, Codable, Sendable { + case staged + case unstaged + case untracked +} + +/// One uncommitted file in the project's working tree, with its unified diff. +public struct SyncGitChange: Codable, Sendable, Identifiable { + public var id: String { "\(kind.rawValue):\(displayPath)" } + /// Path relative to the repository root. + public let displayPath: String + /// Porcelain status letter (M/A/D/R/?/…). + public let statusChar: String + public let kind: SyncGitChangeKind + /// Unified diff text. For untracked files this is an all-added diff. + public let unifiedDiff: String + /// True when `unifiedDiff` was clipped because it exceeded the line cap. + public let truncated: Bool + + public init( + displayPath: String, + statusChar: String, + kind: SyncGitChangeKind, + unifiedDiff: String, + truncated: Bool + ) { + self.displayPath = displayPath + self.statusChar = statusChar + self.kind = kind + self.unifiedDiff = unifiedDiff + self.truncated = truncated + } +} + +/// Desktop reply to a `ThreadChangesRequestPayload`: the two datasets backing +/// the mobile "View Changes" sheet. +public struct ThreadChangesResultPayload: Codable, Sendable { + public let clientRequestID: UUID + public let sessionID: String + /// False when the request could not be served (e.g. not a git repository). + public let ok: Bool + public let errorMessage: String? + /// Every file edited in the thread session. + public let turnEdits: [SyncFileEdit] + /// Uncommitted git changes in the session's project. + public let uncommitted: [SyncGitChange] + + public init( + clientRequestID: UUID, + sessionID: String, + ok: Bool, + errorMessage: String? = nil, + turnEdits: [SyncFileEdit], + uncommitted: [SyncGitChange] + ) { + self.clientRequestID = clientRequestID + self.sessionID = sessionID + self.ok = ok + self.errorMessage = errorMessage + self.turnEdits = turnEdits + self.uncommitted = uncommitted + } +} + +public struct NotificationPayload: Codable, Sendable { + public enum Kind: String, Codable, Sendable { + case responseComplete + case permissionNeeded + case questionNeeded + case mcpDisconnected + case generic + } + public let kind: Kind + public let title: String + public let body: String + public let sessionID: String? + public let projectID: UUID? + public init( + kind: Kind, + title: String, + body: String, + sessionID: String? = nil, + projectID: UUID? = nil + ) { + self.kind = kind + self.title = title + self.body = body + self.sessionID = sessionID + self.projectID = projectID + } +} + +public struct PermissionRequestPayload: Codable, Sendable { + public let requestID: String + public let toolName: String + public let toolInputJSON: String + public let sessionID: String? + public init(requestID: String, toolName: String, toolInputJSON: String, sessionID: String?) { + self.requestID = requestID + self.toolName = toolName + self.toolInputJSON = toolInputJSON + self.sessionID = sessionID + } +} + +public struct PermissionResponsePayload: Codable, Sendable { + public let requestID: String + public let allow: Bool + public let denyReason: String? + public init(requestID: String, allow: Bool, denyReason: String? = nil) { + self.requestID = requestID + self.allow = allow + self.denyReason = denyReason + } +} + +/// One `AskUserQuestion` tool call awaiting the user's answer, mirrored to +/// mobile so it can render the question sheet. `toolInputJSON` is the raw, +/// JSON-encoded `input` of the tool call — it decodes to `[String: JSONValue]`, +/// the same shape `AskUserQuestion(input:)` parses on either platform. +public struct PendingQuestionPayload: Codable, Sendable, Identifiable, Equatable { + public var id: String { toolUseID } + public let toolUseID: String + public let sessionID: String + public let toolInputJSON: String + public init(toolUseID: String, sessionID: String, toolInputJSON: String) { + self.toolUseID = toolUseID + self.sessionID = sessionID + self.toolInputJSON = toolInputJSON + } +} + +/// Desktop → mobile: the complete set of `AskUserQuestion` calls currently +/// awaiting an answer across every session. The desktop is authoritative and +/// re-broadcasts the full set whenever a question is queued or resolved, so +/// mobile mirrors the desktop's question queue exactly (additions and +/// retractions alike). +public struct QuestionQueuePayload: Codable, Sendable { + public let questions: [PendingQuestionPayload] + public init(questions: [PendingQuestionPayload]) { + self.questions = questions + } +} + +/// One answered question inside a `QuestionAnswerPayload`. `values` holds the +/// chosen option labels (or free-form "Other: …" text); a single-select answer +/// has exactly one value, a multi-select answer has zero or more. `multiSelect` +/// mirrors the original question so the desktop rebuilds `.single` vs `.multi`. +public struct QuestionAnswerEntry: Codable, Sendable { + public let questionIndex: Int + public let values: [String] + public let multiSelect: Bool + public init(questionIndex: Int, values: [String], multiSelect: Bool) { + self.questionIndex = questionIndex + self.values = values + self.multiSelect = multiSelect + } +} + +/// Mobile → desktop: the user's answers for one `AskUserQuestion` call. An +/// empty `answers` array means the user chose "Skip All Questions" — the +/// desktop then resolves the tool call as denied instead of injecting answers. +public struct QuestionAnswerPayload: Codable, Sendable { + public let toolUseID: String + public let answers: [QuestionAnswerEntry] + public init(toolUseID: String, answers: [QuestionAnswerEntry]) { + self.toolUseID = toolUseID + self.answers = answers + } +} + +/// Mobile → desktop: the user's decision on a Claude `ExitPlanMode` plan card. +/// Uses a flat wire shape (string action + optional reason) because +/// `PlanDecisionAction` carries an associated value (`rejectWithFeedback`). +public struct PlanDecisionPayload: Codable, Sendable { + public enum Action: String, Codable, Sendable { + case acceptAsk + case acceptWithEdits + case acceptAutoApprove + case reject + case rejectWithFeedback + } + public let toolUseID: String + public let sessionID: String + public let action: Action + /// Free-form revision feedback; only meaningful for `.rejectWithFeedback`. + public let reason: String? + + public init(toolUseID: String, sessionID: String, action: Action, reason: String? = nil) { + self.toolUseID = toolUseID + self.sessionID = sessionID + self.action = action + self.reason = reason + } + + /// Build the wire payload from the shared `PlanDecisionAction` enum. + public init(toolUseID: String, sessionID: String, decision: PlanDecisionAction) { + self.toolUseID = toolUseID + self.sessionID = sessionID + switch decision { + case .acceptAsk: + action = .acceptAsk + reason = nil + case .acceptWithEdits: + action = .acceptWithEdits + reason = nil + case .acceptAutoApprove: + action = .acceptAutoApprove + reason = nil + case .reject: + action = .reject + reason = nil + case .rejectWithFeedback(let feedback): + action = .rejectWithFeedback + reason = feedback + } + } + + /// Map back to the shared `PlanDecisionAction` the desktop's + /// `respondToPlanDecision` consumes. An empty reason is normalized there. + public func toDecisionAction() -> PlanDecisionAction { + switch action { + case .acceptAsk: return .acceptAsk + case .acceptWithEdits: return .acceptWithEdits + case .acceptAutoApprove: return .acceptAutoApprove + case .reject: return .reject + case .rejectWithFeedback: return .rejectWithFeedback(reason: reason ?? "") + } + } +} + +public struct PingPayload: Codable, Sendable { + public let t: Date + public init(t: Date = .now) { self.t = t } +} + +public struct PongPayload: Codable, Sendable { + public let t: Date + public init(t: Date = .now) { self.t = t } +} + diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 1a826dd..8db19f4 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -715,1341 +715,6 @@ public struct RunTaskUpdatePayload: Codable, Sendable { } } -// MARK: - Skills / ACP / MCP remote management - -/// Mobile asks the desktop for the skill marketplace catalog. `forceRefresh` -/// bypasses the desktop's 5-minute marketplace cache. -public struct SkillCatalogRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - public let forceRefresh: Bool - - public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { - self.clientRequestID = clientRequestID - self.forceRefresh = forceRefresh - } -} - -/// One marketplace plugin flattened from the desktop's `MarketplacePlugin` -/// plus its current install state. `id` mirrors `MarketplacePlugin.id`. -public struct MobileSkillPlugin: Codable, Sendable, Identifiable, Equatable { - public let id: String - public let name: String - public let summary: String - public let author: String - public let category: String - public let categoryLabel: String - public let marketplace: String - public let marketplaceLabel: String - public let homepage: String - public let isInstalled: Bool - - public init( - id: String, - name: String, - summary: String, - author: String, - category: String, - categoryLabel: String, - marketplace: String, - marketplaceLabel: String, - homepage: String, - isInstalled: Bool - ) { - self.id = id - self.name = name - self.summary = summary - self.author = author - self.category = category - self.categoryLabel = categoryLabel - self.marketplace = marketplace - self.marketplaceLabel = marketplaceLabel - self.homepage = homepage - self.isInstalled = isInstalled - } -} - -public struct MobileSkillSource: Codable, Sendable, Identifiable, Equatable { - public let id: String - public let displayName: String - - public init(id: String, displayName: String) { - self.id = id - self.displayName = displayName - } -} - -public struct SkillCatalogResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let ok: Bool - public let errorMessage: String? - public let plugins: [MobileSkillPlugin] - public let sources: [MobileSkillSource] - - public init( - clientRequestID: UUID, - ok: Bool, - errorMessage: String? = nil, - plugins: [MobileSkillPlugin] = [], - sources: [MobileSkillSource] = [] - ) { - self.clientRequestID = clientRequestID - self.ok = ok - self.errorMessage = errorMessage - self.plugins = plugins - self.sources = sources - } -} - -/// Mobile asks the desktop to install or remove a marketplace skill. `pluginID` -/// is the catalog id; the desktop re-resolves the authoritative plugin from its -/// own freshly-fetched catalog. -public struct SkillMutationRequestPayload: Codable, Sendable { - public enum Operation: String, Codable, Sendable { - case install - case uninstall - } - - public let clientRequestID: UUID - public let operation: Operation - public let pluginID: String - - public init(clientRequestID: UUID = UUID(), operation: Operation, pluginID: String) { - self.clientRequestID = clientRequestID - self.operation = operation - self.pluginID = pluginID - } -} - -public struct SkillMutationResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let operation: SkillMutationRequestPayload.Operation - public let pluginID: String - public let ok: Bool - public let errorMessage: String? - public let plugins: [MobileSkillPlugin] - public let sources: [MobileSkillSource] - - public init( - clientRequestID: UUID, - operation: SkillMutationRequestPayload.Operation, - pluginID: String, - ok: Bool, - errorMessage: String? = nil, - plugins: [MobileSkillPlugin] = [], - sources: [MobileSkillSource] = [] - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.pluginID = pluginID - self.ok = ok - self.errorMessage = errorMessage - self.plugins = plugins - self.sources = sources - } -} - -public struct SkillSourceMutationRequestPayload: Codable, Sendable { - public enum Operation: String, Codable, Sendable { - case add - case remove - } - - public let clientRequestID: UUID - public let operation: Operation - public let sourceID: String? - public let gitURL: String? - public let ref: String? - - public init( - clientRequestID: UUID = UUID(), - operation: Operation, - sourceID: String? = nil, - gitURL: String? = nil, - ref: String? = nil - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.sourceID = sourceID - self.gitURL = gitURL - self.ref = ref - } -} - -public struct SkillSourceMutationResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let operation: SkillSourceMutationRequestPayload.Operation - public let sourceID: String? - public let ok: Bool - public let errorMessage: String? - public let plugins: [MobileSkillPlugin] - public let sources: [MobileSkillSource] - - public init( - clientRequestID: UUID, - operation: SkillSourceMutationRequestPayload.Operation, - sourceID: String? = nil, - ok: Bool, - errorMessage: String? = nil, - plugins: [MobileSkillPlugin] = [], - sources: [MobileSkillSource] = [] - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.sourceID = sourceID - self.ok = ok - self.errorMessage = errorMessage - self.plugins = plugins - self.sources = sources - } -} - -/// Mobile asks the desktop for the ACP agent registry plus installed clients. -public struct ACPRegistryRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - public let forceRefresh: Bool - - public init(clientRequestID: UUID = UUID(), forceRefresh: Bool = false) { - self.clientRequestID = clientRequestID - self.forceRefresh = forceRefresh - } -} - -/// A registry agent flattened from the desktop's `ACPRegistryAgent`, plus -/// whether a matching client is already installed locally. -public struct MobileACPRegistryAgent: Codable, Sendable, Identifiable, Equatable { - public let id: String - public let name: String - public let version: String - public let summary: String - public let authors: [String] - public let license: String? - public let website: String? - public let iconURL: String? - public let isInstalled: Bool - public let hasBinary: Bool - public let hasNpx: Bool - public let hasUvx: Bool - - public init( - id: String, - name: String, - version: String, - summary: String, - authors: [String] = [], - license: String? = nil, - website: String? = nil, - iconURL: String? = nil, - isInstalled: Bool, - hasBinary: Bool, - hasNpx: Bool, - hasUvx: Bool - ) { - self.id = id - self.name = name - self.version = version - self.summary = summary - self.authors = authors - self.license = license - self.website = website - self.iconURL = iconURL - self.isInstalled = isInstalled - self.hasBinary = hasBinary - self.hasNpx = hasNpx - self.hasUvx = hasUvx - } -} - -/// An installed ACP client mirrored from the desktop's `ACPClientSpec`. -public struct MobileACPClient: Codable, Sendable, Identifiable, Equatable { - public let id: String - public let registryId: String? - public let displayName: String - public let enabled: Bool - public let launchKind: String - public let modelCount: Int - public let iconURL: String? - - public init( - id: String, - registryId: String? = nil, - displayName: String, - enabled: Bool, - launchKind: String, - modelCount: Int, - iconURL: String? = nil - ) { - self.id = id - self.registryId = registryId - self.displayName = displayName - self.enabled = enabled - self.launchKind = launchKind - self.modelCount = modelCount - self.iconURL = iconURL - } -} - -public struct ACPRegistryResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let ok: Bool - public let errorMessage: String? - public let registryAgents: [MobileACPRegistryAgent] - public let installedClients: [MobileACPClient] - - public init( - clientRequestID: UUID, - ok: Bool, - errorMessage: String? = nil, - registryAgents: [MobileACPRegistryAgent] = [], - installedClients: [MobileACPClient] = [] - ) { - self.clientRequestID = clientRequestID - self.ok = ok - self.errorMessage = errorMessage - self.registryAgents = registryAgents - self.installedClients = installedClients - } -} - -/// Mobile asks the desktop to install an ACP agent from the registry, remove an -/// installed client, or toggle a client's enabled flag. -public struct ACPMutationRequestPayload: Codable, Sendable { - public enum Operation: String, Codable, Sendable { - case install - case uninstall - case setEnabled - } - - public let clientRequestID: UUID - public let operation: Operation - public let registryAgentID: String? - public let clientID: String? - public let enabled: Bool? - - public init( - clientRequestID: UUID = UUID(), - operation: Operation, - registryAgentID: String? = nil, - clientID: String? = nil, - enabled: Bool? = nil - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.registryAgentID = registryAgentID - self.clientID = clientID - self.enabled = enabled - } -} - -public struct ACPMutationResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let operation: ACPMutationRequestPayload.Operation - public let ok: Bool - public let errorMessage: String? - public let registryAgents: [MobileACPRegistryAgent] - public let installedClients: [MobileACPClient] - - public init( - clientRequestID: UUID, - operation: ACPMutationRequestPayload.Operation, - ok: Bool, - errorMessage: String? = nil, - registryAgents: [MobileACPRegistryAgent] = [], - installedClients: [MobileACPClient] = [] - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.ok = ok - self.errorMessage = errorMessage - self.registryAgents = registryAgents - self.installedClients = installedClients - } -} - -/// Mobile asks the desktop for the configured global MCP servers. -public struct MCPConfigRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - - public init(clientRequestID: UUID = UUID()) { - self.clientRequestID = clientRequestID - } -} - -/// A plain key/value pair for MCP environment variables and headers. The -/// desktop's `MCPKeyValue` carries a non-Codable UUID, so the wire uses this. -public struct MobileMCPKeyValue: Codable, Sendable, Equatable, Hashable { - public let key: String - public let value: String - - public init(key: String, value: String) { - self.key = key - self.value = value - } -} - -/// One global MCP server flattened from the desktop's `MCPServerRecord`. -public struct MobileMCPServer: Codable, Sendable, Identifiable, Equatable { - public var id: String { name } - - public let name: String - public let transport: String - public let url: String? - public let command: String? - public let args: [String] - public let env: [MobileMCPKeyValue] - public let headers: [MobileMCPKeyValue] - public let isGloballyEnabled: Bool - public let endpoint: String - - public init( - name: String, - transport: String, - url: String? = nil, - command: String? = nil, - args: [String] = [], - env: [MobileMCPKeyValue] = [], - headers: [MobileMCPKeyValue] = [], - isGloballyEnabled: Bool, - endpoint: String - ) { - self.name = name - self.transport = transport - self.url = url - self.command = command - self.args = args - self.env = env - self.headers = headers - self.isGloballyEnabled = isGloballyEnabled - self.endpoint = endpoint - } -} - -public struct MCPConfigResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let ok: Bool - public let errorMessage: String? - public let servers: [MobileMCPServer] - - public init( - clientRequestID: UUID, - ok: Bool, - errorMessage: String? = nil, - servers: [MobileMCPServer] = [] - ) { - self.clientRequestID = clientRequestID - self.ok = ok - self.errorMessage = errorMessage - self.servers = servers - } -} - -/// Mobile asks the desktop to add/upsert, remove, or toggle a global MCP server. -public struct MCPMutationRequestPayload: Codable, Sendable { - public enum Operation: String, Codable, Sendable { - case add - case remove - case setEnabled - } - - public let clientRequestID: UUID - public let operation: Operation - public let serverName: String - public let server: MobileMCPServer? - public let enabled: Bool? - - public init( - clientRequestID: UUID = UUID(), - operation: Operation, - serverName: String, - server: MobileMCPServer? = nil, - enabled: Bool? = nil - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.serverName = serverName - self.server = server - self.enabled = enabled - } -} - -public struct MCPMutationResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let operation: MCPMutationRequestPayload.Operation - public let serverName: String - public let ok: Bool - public let errorMessage: String? - public let servers: [MobileMCPServer] - - public init( - clientRequestID: UUID, - operation: MCPMutationRequestPayload.Operation, - serverName: String, - ok: Bool, - errorMessage: String? = nil, - servers: [MobileMCPServer] = [] - ) { - self.clientRequestID = clientRequestID - self.operation = operation - self.serverName = serverName - self.ok = ok - self.errorMessage = errorMessage - self.servers = servers - } -} - -public struct MobileBranchBriefing: Codable, Sendable, Identifiable, Equatable { - public var id: String { "\(projectId.uuidString)::\(branch)" } - - public let projectId: UUID - public let branch: String - public let briefing: String - public let updatedAt: Date - - public init(projectId: UUID, branch: String, briefing: String, updatedAt: Date) { - self.projectId = projectId - self.branch = branch - self.briefing = briefing - self.updatedAt = updatedAt - } -} - -public struct MobileThreadSummary: Codable, Sendable, Identifiable, Equatable { - public var id: String { sessionId } - - public let sessionId: String - public let projectId: UUID - public let branch: String - public let title: String - public let summary: String - public let updatedAt: Date - - public init( - sessionId: String, - projectId: UUID, - branch: String, - title: String, - summary: String, - updatedAt: Date - ) { - self.sessionId = sessionId - self.projectId = projectId - self.branch = branch - self.title = title - self.summary = summary - self.updatedAt = updatedAt - } -} - -/// One selectable summarization provider, mirrored from the desktop's -/// `SummarizationProvider` enum. Sent as data rather than the enum itself -/// because the enum — and its hardware-dependent availability (Apple -/// Foundation Model) — lives in the desktop target. -public struct SummarizationProviderOption: Codable, Sendable, Equatable, Identifiable { - /// Raw value of the desktop's `SummarizationProvider` case. - public let id: String - public let displayName: String - - public init(id: String, displayName: String) { - self.id = id - self.displayName = displayName - } -} - -public struct MobileSettingsSnapshot: Codable, Sendable, Equatable { - public let selectedAgentProvider: AgentProvider - public let selectedModel: String - public let selectedACPClientId: String - public let selectedEffort: String - public let permissionMode: PermissionMode - public let summarizationProvider: String - public let summarizationProviderDisplayName: String - public let openAISummarizationEndpoint: String - public let openAISummarizationModel: String - public let notificationsEnabled: Bool - public let focusMode: Bool - public let autoArchiveEnabled: Bool - public let archiveRetentionDays: Int - public let autoPreviewSettings: AttachmentAutoPreviewSettings - public let availableEfforts: [String] - /// All agent models discovered on the desktop, flattened across providers - /// and ACP clients. Lets mobile show a model picker without re-deriving the - /// list itself. Optional for backward compatibility with older desktops. - public let availableModels: [AgentModel]? - /// Model picker sections, mirroring the desktop layout — Claude Code, - /// Codex, and one section per enabled ACP client. Lets mobile reproduce the - /// desktop's per-ACP-client grouping instead of collapsing every ACP client - /// into a single bucket. Optional for backward compatibility with older - /// desktops, which sent only the flattened `availableModels` list. - public let modelSections: [AgentModelSection]? - /// Summarization providers the desktop currently offers, so mobile can - /// render a provider picker without re-deriving hardware availability. - /// Optional for backward compatibility with older desktops. - public let availableSummarizationProviders: [SummarizationProviderOption]? - /// Model identifiers fetched from the OpenAI-compatible summarization - /// endpoint, so mobile can render a model picker. Empty/`nil` when the - /// desktop hasn't fetched them yet (e.g. no API key configured). - public let openAISummarizationModels: [String]? - - public init( - selectedAgentProvider: AgentProvider, - selectedModel: String, - selectedACPClientId: String, - selectedEffort: String, - permissionMode: PermissionMode, - summarizationProvider: String, - summarizationProviderDisplayName: String, - openAISummarizationEndpoint: String, - openAISummarizationModel: String, - notificationsEnabled: Bool, - focusMode: Bool, - autoArchiveEnabled: Bool, - archiveRetentionDays: Int, - autoPreviewSettings: AttachmentAutoPreviewSettings, - availableEfforts: [String], - availableModels: [AgentModel]? = nil, - modelSections: [AgentModelSection]? = nil, - availableSummarizationProviders: [SummarizationProviderOption]? = nil, - openAISummarizationModels: [String]? = nil - ) { - self.selectedAgentProvider = selectedAgentProvider - self.selectedModel = selectedModel - self.selectedACPClientId = selectedACPClientId - self.selectedEffort = selectedEffort - self.permissionMode = permissionMode - self.summarizationProvider = summarizationProvider - self.summarizationProviderDisplayName = summarizationProviderDisplayName - self.openAISummarizationEndpoint = openAISummarizationEndpoint - self.openAISummarizationModel = openAISummarizationModel - self.notificationsEnabled = notificationsEnabled - self.focusMode = focusMode - self.autoArchiveEnabled = autoArchiveEnabled - self.archiveRetentionDays = archiveRetentionDays - self.autoPreviewSettings = autoPreviewSettings - self.availableEfforts = availableEfforts - self.availableModels = availableModels - self.modelSections = modelSections - self.availableSummarizationProviders = availableSummarizationProviders - self.openAISummarizationModels = openAISummarizationModels - } - - private enum CodingKeys: String, CodingKey { - case selectedAgentProvider, selectedModel, selectedACPClientId, selectedEffort - case permissionMode, summarizationProvider, summarizationProviderDisplayName - case openAISummarizationEndpoint, openAISummarizationModel - case notificationsEnabled, focusMode, autoArchiveEnabled, archiveRetentionDays - case autoPreviewSettings, availableEfforts, availableModels, modelSections - case availableSummarizationProviders, openAISummarizationModels - } - - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - selectedAgentProvider = try c.decode(AgentProvider.self, forKey: .selectedAgentProvider) - selectedModel = try c.decode(String.self, forKey: .selectedModel) - selectedACPClientId = try c.decode(String.self, forKey: .selectedACPClientId) - selectedEffort = try c.decode(String.self, forKey: .selectedEffort) - permissionMode = try c.decode(PermissionMode.self, forKey: .permissionMode) - summarizationProvider = try c.decode(String.self, forKey: .summarizationProvider) - summarizationProviderDisplayName = try c.decode(String.self, forKey: .summarizationProviderDisplayName) - openAISummarizationEndpoint = try c.decode(String.self, forKey: .openAISummarizationEndpoint) - openAISummarizationModel = try c.decode(String.self, forKey: .openAISummarizationModel) - notificationsEnabled = try c.decode(Bool.self, forKey: .notificationsEnabled) - focusMode = try c.decode(Bool.self, forKey: .focusMode) - autoArchiveEnabled = try c.decode(Bool.self, forKey: .autoArchiveEnabled) - archiveRetentionDays = try c.decode(Int.self, forKey: .archiveRetentionDays) - autoPreviewSettings = try c.decode(AttachmentAutoPreviewSettings.self, forKey: .autoPreviewSettings) - availableEfforts = try c.decode([String].self, forKey: .availableEfforts) - availableModels = try c.decodeIfPresent([AgentModel].self, forKey: .availableModels) - modelSections = try c.decodeIfPresent([AgentModelSection].self, forKey: .modelSections) - availableSummarizationProviders = try c.decodeIfPresent( - [SummarizationProviderOption].self, forKey: .availableSummarizationProviders - ) - openAISummarizationModels = try c.decodeIfPresent( - [String].self, forKey: .openAISummarizationModels - ) - } -} - -public struct MobileSettingsUpdatePayload: Codable, Sendable { - public let selectedAgentProvider: AgentProvider? - public let selectedModel: String? - public let selectedACPClientId: String? - public let selectedEffort: String? - public let permissionMode: PermissionMode? - /// Raw value of a `SummarizationProvider` case the user picked on mobile. - public let summarizationProvider: String? - /// Model identifier picked on mobile for the OpenAI-compatible endpoint. - public let openAISummarizationModel: String? - public let notificationsEnabled: Bool? - public let focusMode: Bool? - public let autoArchiveEnabled: Bool? - public let archiveRetentionDays: Int? - public let autoPreviewSettings: AttachmentAutoPreviewSettings? - - public init( - selectedAgentProvider: AgentProvider? = nil, - selectedModel: String? = nil, - selectedACPClientId: String? = nil, - selectedEffort: String? = nil, - permissionMode: PermissionMode? = nil, - summarizationProvider: String? = nil, - openAISummarizationModel: String? = nil, - notificationsEnabled: Bool? = nil, - focusMode: Bool? = nil, - autoArchiveEnabled: Bool? = nil, - archiveRetentionDays: Int? = nil, - autoPreviewSettings: AttachmentAutoPreviewSettings? = nil - ) { - self.selectedAgentProvider = selectedAgentProvider - self.selectedModel = selectedModel - self.selectedACPClientId = selectedACPClientId - self.selectedEffort = selectedEffort - self.permissionMode = permissionMode - self.summarizationProvider = summarizationProvider - self.openAISummarizationModel = openAISummarizationModel - self.notificationsEnabled = notificationsEnabled - self.focusMode = focusMode - self.autoArchiveEnabled = autoArchiveEnabled - self.archiveRetentionDays = archiveRetentionDays - self.autoPreviewSettings = autoPreviewSettings - } -} - -public struct SessionProgressSnapshot: Codable, Sendable, Equatable { - public let done: Int - public let total: Int - public let inProgress: Bool - - public init(done: Int, total: Int, inProgress: Bool) { - self.done = done - self.total = total - self.inProgress = inProgress - } -} - -public enum SessionAttentionKind: String, Codable, Sendable, Equatable { - case permission - case question -} - -public struct SessionSummary: Codable, Sendable, Identifiable { - public let id: String - public let projectId: UUID - public let title: String - public let updatedAt: Date - public let isPinned: Bool - public let isArchived: Bool - public let isStreaming: Bool - public let attention: SessionAttentionKind? - public let progress: SessionProgressSnapshot? - /// Latest todo items for this session. Claude sessions can still derive - /// these from `TodoWrite` messages, but Codex plan updates are persisted as - /// snapshots instead of message tool calls, so the mobile app needs the - /// desktop-owned item list. - public let todos: [TodoItem]? - /// Messages waiting to be sent once the active turn finishes. Mirrored from - /// the desktop's per-session queue (threadStore). `nil` when the summary - /// comes from an older desktop that doesn't know about queue sync. - public let queuedMessages: [QueuedUserMessage]? - /// Whether the session's stream finished while the user wasn't viewing it - /// and it hasn't been opened since. Mirrors the desktop's - /// `hasUncheckedCompletion` so mobile can show the same green - /// "finished, unread" indicator. Defaults to `false` for summaries from - /// an older desktop that predates this field. - public let hasUncheckedCompletion: Bool - - public init( - id: String, - projectId: UUID, - title: String, - updatedAt: Date, - isPinned: Bool, - isArchived: Bool, - isStreaming: Bool = false, - attention: SessionAttentionKind? = nil, - progress: SessionProgressSnapshot? = nil, - todos: [TodoItem]? = nil, - queuedMessages: [QueuedUserMessage]? = nil, - hasUncheckedCompletion: Bool = false - ) { - self.id = id - self.projectId = projectId - self.title = title - self.updatedAt = updatedAt - self.isPinned = isPinned - self.isArchived = isArchived - self.isStreaming = isStreaming - self.attention = attention - self.progress = progress - self.todos = todos - self.queuedMessages = queuedMessages - self.hasUncheckedCompletion = hasUncheckedCompletion - } - - private enum CodingKeys: String, CodingKey { - case id, projectId, title, updatedAt, isPinned, isArchived, isStreaming, attention, progress, todos, queuedMessages, hasUncheckedCompletion - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - projectId = try container.decode(UUID.self, forKey: .projectId) - title = try container.decode(String.self, forKey: .title) - updatedAt = try container.decode(Date.self, forKey: .updatedAt) - isPinned = try container.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false - isArchived = try container.decodeIfPresent(Bool.self, forKey: .isArchived) ?? false - isStreaming = try container.decodeIfPresent(Bool.self, forKey: .isStreaming) ?? false - attention = try container.decodeIfPresent(SessionAttentionKind.self, forKey: .attention) - progress = try container.decodeIfPresent(SessionProgressSnapshot.self, forKey: .progress) - todos = try container.decodeIfPresent([TodoItem].self, forKey: .todos) - queuedMessages = try container.decodeIfPresent([QueuedUserMessage].self, forKey: .queuedMessages) - hasUncheckedCompletion = try container.decodeIfPresent(Bool.self, forKey: .hasUncheckedCompletion) ?? false - } -} - -public struct SessionUpdatePayload: Codable, Sendable { - public enum Kind: String, Codable, Sendable { - case messageAppended - case messageUpdated - case streamingStarted - case streamingFinished - case statusChanged - } - public let sessionID: String - public let kind: Kind - public let message: ChatMessage? - public let isStreaming: Bool? - /// Whether the agent is currently producing reasoning/thinking tokens (as - /// opposed to output text). Drives the mobile streaming indicator's - /// "Thinking…" label, mirroring the desktop. Optional for backward - /// compatibility with desktops that predate thinking sync. - public let isThinking: Bool? - public let summary: SessionSummary? - public let previousSessionID: String? - - public init( - sessionID: String, - kind: Kind, - message: ChatMessage? = nil, - isStreaming: Bool? = nil, - isThinking: Bool? = nil, - summary: SessionSummary? = nil, - previousSessionID: String? = nil - ) { - self.sessionID = sessionID - self.kind = kind - self.message = message - self.isStreaming = isStreaming - self.isThinking = isThinking - self.summary = summary - self.previousSessionID = previousSessionID - } -} - -public struct SubscribeSessionPayload: Codable, Sendable { - public let sessionID: String? - public init(sessionID: String?) { self.sessionID = sessionID } -} - -public struct UserMessagePayload: Codable, Sendable { - public let clientMessageID: UUID - public let sessionID: String - public let text: String - public init(clientMessageID: UUID = UUID(), sessionID: String, text: String) { - self.clientMessageID = clientMessageID - self.sessionID = sessionID - self.text = text - } -} - -public struct CancelStreamPayload: Codable, Sendable { - public let sessionID: String - public init(sessionID: String) { - self.sessionID = sessionID - } -} - -/// A user message that's waiting for the active turn to finish before being -/// sent to the agent. Mirrored to mobile via `SessionSummary.queuedMessages`. -public struct QueuedUserMessage: Codable, Sendable, Identifiable, Equatable { - public let id: UUID - public let text: String - public init(id: UUID, text: String) { - self.id = id - self.text = text - } -} - -/// Asks the desktop to drop the queued message from threadStore. Used by the -/// mobile UI when the user swipes a queued row away. The desktop never tries -/// to send the message after this point. -public struct RemoveQueuedMessagePayload: Codable, Sendable { - public let sessionID: String - public let queuedMessageID: UUID - public init(sessionID: String, queuedMessageID: UUID) { - self.sessionID = sessionID - self.queuedMessageID = queuedMessageID - } -} - -public struct NewSessionRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - public let projectID: UUID - public let initialText: String? - /// Per-thread agent configuration captured in the mobile new-thread sheet. - /// All optional for wire-compatibility with older builds — synthesized - /// `Codable` decodes missing keys as `nil`, in which case the desktop falls - /// back to its global defaults. - public let selectedAgentProvider: AgentProvider? - public let selectedModel: String? - public let selectedACPClientId: String? - public let selectedEffort: String? - public let permissionMode: PermissionMode? - /// When `true`, the desktop starts the thread in plan mode - /// (CLI `--permission-mode plan`). - public let planMode: Bool? - public init( - clientRequestID: UUID = UUID(), - projectID: UUID, - initialText: String? = nil, - selectedAgentProvider: AgentProvider? = nil, - selectedModel: String? = nil, - selectedACPClientId: String? = nil, - selectedEffort: String? = nil, - permissionMode: PermissionMode? = nil, - planMode: Bool? = nil - ) { - self.clientRequestID = clientRequestID - self.projectID = projectID - self.initialText = initialText - self.selectedAgentProvider = selectedAgentProvider - self.selectedModel = selectedModel - self.selectedACPClientId = selectedACPClientId - self.selectedEffort = selectedEffort - self.permissionMode = permissionMode - self.planMode = planMode - } -} - -/// Mobile-initiated lifecycle action on an existing thread: rename, archive, -/// unarchive, or delete. The desktop applies the action against its -/// authoritative session store and broadcasts a fresh snapshot so every paired -/// device reconciles. Fire-and-forget — there is no dedicated result payload. -public struct ThreadActionRequestPayload: Codable, Sendable { - public enum Action: String, Codable, Sendable { - case rename - case archive - case unarchive - case delete - } - - public let clientRequestID: UUID - public let sessionID: String - public let action: Action - /// New title, required only when `action == .rename`. - public let newTitle: String? - - public init( - clientRequestID: UUID = UUID(), - sessionID: String, - action: Action, - newTitle: String? = nil - ) { - self.clientRequestID = clientRequestID - self.sessionID = sessionID - self.action = action - self.newTitle = newTitle - } -} - -/// Mobile-initiated request for an older page of a thread's messages. Mobile -/// holds only the most recent window (see `SnapshotPayload.activeSessionMessages`) -/// and pages backwards as the user scrolls up. The desktop replies with a -/// `MoreMessagesPayload` carrying the messages strictly older than -/// `beforeMessageID`. -public struct LoadMoreMessagesRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - public let sessionID: String - /// The oldest message the requester currently holds. The desktop returns - /// messages that come before this one in the thread. - public let beforeMessageID: UUID - /// How many older messages to return at most. - public let limit: Int - - public init( - clientRequestID: UUID = UUID(), - sessionID: String, - beforeMessageID: UUID, - limit: Int - ) { - self.clientRequestID = clientRequestID - self.sessionID = sessionID - self.beforeMessageID = beforeMessageID - self.limit = limit - } -} - -/// Desktop reply to a `LoadMoreMessagesRequestPayload`: one older page of a -/// thread's messages, to be prepended to the requester's local window. -public struct MoreMessagesPayload: Codable, Sendable { - public let clientRequestID: UUID - public let sessionID: String - /// Older messages in chronological order (oldest first). - public let messages: [ChatMessage] - /// Whether messages older than this page still remain. - public let hasMore: Bool - - public init( - clientRequestID: UUID, - sessionID: String, - messages: [ChatMessage], - hasMore: Bool - ) { - self.clientRequestID = clientRequestID - self.sessionID = sessionID - self.messages = messages - self.hasMore = hasMore - } -} - -/// Mobile-initiated search across all threads and projects. The desktop is -/// the authoritative source of session content, so the heavy lifting (semantic -/// matching, title/project lookups) lives there; mobile just renders results. -public struct SearchRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - public let query: String - public let limit: Int - public init(clientRequestID: UUID = UUID(), query: String, limit: Int = 25) { - self.clientRequestID = clientRequestID - self.query = query - self.limit = limit - } -} - -public struct SearchHit: Codable, Sendable, Identifiable, Equatable { - public let sessionID: String - public let projectID: UUID - public let title: String - public let snippet: String - public let updatedAt: Date - public let score: Float - public var id: String { sessionID } - public init( - sessionID: String, - projectID: UUID, - title: String, - snippet: String, - updatedAt: Date, - score: Float - ) { - self.sessionID = sessionID - self.projectID = projectID - self.title = title - self.snippet = snippet - self.updatedAt = updatedAt - self.score = score - } -} - -public struct SearchResultsPayload: Codable, Sendable { - public let clientRequestID: UUID - public let query: String - public let projectIDs: [UUID] - public let threadHits: [SearchHit] - public init(clientRequestID: UUID, query: String, projectIDs: [UUID], threadHits: [SearchHit]) { - self.clientRequestID = clientRequestID - self.query = query - self.projectIDs = projectIDs - self.threadHits = threadHits - } -} - -// MARK: - Thread changes - -/// Mobile-initiated request for the change overview of a thread: every file -/// edited in the thread session plus the project's uncommitted git changes. -/// The desktop is the authoritative source for both (SwiftData edit history and -/// the working tree), so it builds the whole `ThreadChangesResultPayload`. -public struct ThreadChangesRequestPayload: Codable, Sendable { - public let clientRequestID: UUID - public let sessionID: String - - public init(clientRequestID: UUID = UUID(), sessionID: String) { - self.clientRequestID = clientRequestID - self.sessionID = sessionID - } -} - -/// One old/new replacement pair. Wire form of `PreviewFile.EditHunk`, which is -/// not itself `Codable`. -public struct SyncEditHunk: Codable, Sendable, Equatable { - public let oldString: String - public let newString: String - - public init(oldString: String, newString: String) { - self.oldString = oldString - self.newString = newString - } -} - -/// Aggregated edits to a single file across a whole thread session. Wire form -/// of `FileEditSummary`. -public struct SyncFileEdit: Codable, Sendable, Identifiable { - public var id: String { path } - public let path: String - public let name: String - /// True if any contributing tool was Write — old content was overwritten. - public let containsWrite: Bool - public let hunks: [SyncEditHunk] - - public init(path: String, name: String, containsWrite: Bool, hunks: [SyncEditHunk]) { - self.path = path - self.name = name - self.containsWrite = containsWrite - self.hunks = hunks - } -} - -/// Which side of the working tree a git change lives on. -public enum SyncGitChangeKind: String, Codable, Sendable { - case staged - case unstaged - case untracked -} - -/// One uncommitted file in the project's working tree, with its unified diff. -public struct SyncGitChange: Codable, Sendable, Identifiable { - public var id: String { "\(kind.rawValue):\(displayPath)" } - /// Path relative to the repository root. - public let displayPath: String - /// Porcelain status letter (M/A/D/R/?/…). - public let statusChar: String - public let kind: SyncGitChangeKind - /// Unified diff text. For untracked files this is an all-added diff. - public let unifiedDiff: String - /// True when `unifiedDiff` was clipped because it exceeded the line cap. - public let truncated: Bool - - public init( - displayPath: String, - statusChar: String, - kind: SyncGitChangeKind, - unifiedDiff: String, - truncated: Bool - ) { - self.displayPath = displayPath - self.statusChar = statusChar - self.kind = kind - self.unifiedDiff = unifiedDiff - self.truncated = truncated - } -} - -/// Desktop reply to a `ThreadChangesRequestPayload`: the two datasets backing -/// the mobile "View Changes" sheet. -public struct ThreadChangesResultPayload: Codable, Sendable { - public let clientRequestID: UUID - public let sessionID: String - /// False when the request could not be served (e.g. not a git repository). - public let ok: Bool - public let errorMessage: String? - /// Every file edited in the thread session. - public let turnEdits: [SyncFileEdit] - /// Uncommitted git changes in the session's project. - public let uncommitted: [SyncGitChange] - - public init( - clientRequestID: UUID, - sessionID: String, - ok: Bool, - errorMessage: String? = nil, - turnEdits: [SyncFileEdit], - uncommitted: [SyncGitChange] - ) { - self.clientRequestID = clientRequestID - self.sessionID = sessionID - self.ok = ok - self.errorMessage = errorMessage - self.turnEdits = turnEdits - self.uncommitted = uncommitted - } -} - -public struct NotificationPayload: Codable, Sendable { - public enum Kind: String, Codable, Sendable { - case responseComplete - case permissionNeeded - case questionNeeded - case mcpDisconnected - case generic - } - public let kind: Kind - public let title: String - public let body: String - public let sessionID: String? - public let projectID: UUID? - public init( - kind: Kind, - title: String, - body: String, - sessionID: String? = nil, - projectID: UUID? = nil - ) { - self.kind = kind - self.title = title - self.body = body - self.sessionID = sessionID - self.projectID = projectID - } -} - -public struct PermissionRequestPayload: Codable, Sendable { - public let requestID: String - public let toolName: String - public let toolInputJSON: String - public let sessionID: String? - public init(requestID: String, toolName: String, toolInputJSON: String, sessionID: String?) { - self.requestID = requestID - self.toolName = toolName - self.toolInputJSON = toolInputJSON - self.sessionID = sessionID - } -} - -public struct PermissionResponsePayload: Codable, Sendable { - public let requestID: String - public let allow: Bool - public let denyReason: String? - public init(requestID: String, allow: Bool, denyReason: String? = nil) { - self.requestID = requestID - self.allow = allow - self.denyReason = denyReason - } -} - -/// One `AskUserQuestion` tool call awaiting the user's answer, mirrored to -/// mobile so it can render the question sheet. `toolInputJSON` is the raw, -/// JSON-encoded `input` of the tool call — it decodes to `[String: JSONValue]`, -/// the same shape `AskUserQuestion(input:)` parses on either platform. -public struct PendingQuestionPayload: Codable, Sendable, Identifiable, Equatable { - public var id: String { toolUseID } - public let toolUseID: String - public let sessionID: String - public let toolInputJSON: String - public init(toolUseID: String, sessionID: String, toolInputJSON: String) { - self.toolUseID = toolUseID - self.sessionID = sessionID - self.toolInputJSON = toolInputJSON - } -} - -/// Desktop → mobile: the complete set of `AskUserQuestion` calls currently -/// awaiting an answer across every session. The desktop is authoritative and -/// re-broadcasts the full set whenever a question is queued or resolved, so -/// mobile mirrors the desktop's question queue exactly (additions and -/// retractions alike). -public struct QuestionQueuePayload: Codable, Sendable { - public let questions: [PendingQuestionPayload] - public init(questions: [PendingQuestionPayload]) { - self.questions = questions - } -} - -/// One answered question inside a `QuestionAnswerPayload`. `values` holds the -/// chosen option labels (or free-form "Other: …" text); a single-select answer -/// has exactly one value, a multi-select answer has zero or more. `multiSelect` -/// mirrors the original question so the desktop rebuilds `.single` vs `.multi`. -public struct QuestionAnswerEntry: Codable, Sendable { - public let questionIndex: Int - public let values: [String] - public let multiSelect: Bool - public init(questionIndex: Int, values: [String], multiSelect: Bool) { - self.questionIndex = questionIndex - self.values = values - self.multiSelect = multiSelect - } -} - -/// Mobile → desktop: the user's answers for one `AskUserQuestion` call. An -/// empty `answers` array means the user chose "Skip All Questions" — the -/// desktop then resolves the tool call as denied instead of injecting answers. -public struct QuestionAnswerPayload: Codable, Sendable { - public let toolUseID: String - public let answers: [QuestionAnswerEntry] - public init(toolUseID: String, answers: [QuestionAnswerEntry]) { - self.toolUseID = toolUseID - self.answers = answers - } -} - -/// Mobile → desktop: the user's decision on a Claude `ExitPlanMode` plan card. -/// Uses a flat wire shape (string action + optional reason) because -/// `PlanDecisionAction` carries an associated value (`rejectWithFeedback`). -public struct PlanDecisionPayload: Codable, Sendable { - public enum Action: String, Codable, Sendable { - case acceptAsk - case acceptWithEdits - case acceptAutoApprove - case reject - case rejectWithFeedback - } - public let toolUseID: String - public let sessionID: String - public let action: Action - /// Free-form revision feedback; only meaningful for `.rejectWithFeedback`. - public let reason: String? - - public init(toolUseID: String, sessionID: String, action: Action, reason: String? = nil) { - self.toolUseID = toolUseID - self.sessionID = sessionID - self.action = action - self.reason = reason - } - - /// Build the wire payload from the shared `PlanDecisionAction` enum. - public init(toolUseID: String, sessionID: String, decision: PlanDecisionAction) { - self.toolUseID = toolUseID - self.sessionID = sessionID - switch decision { - case .acceptAsk: - action = .acceptAsk - reason = nil - case .acceptWithEdits: - action = .acceptWithEdits - reason = nil - case .acceptAutoApprove: - action = .acceptAutoApprove - reason = nil - case .reject: - action = .reject - reason = nil - case .rejectWithFeedback(let feedback): - action = .rejectWithFeedback - reason = feedback - } - } - - /// Map back to the shared `PlanDecisionAction` the desktop's - /// `respondToPlanDecision` consumes. An empty reason is normalized there. - public func toDecisionAction() -> PlanDecisionAction { - switch action { - case .acceptAsk: return .acceptAsk - case .acceptWithEdits: return .acceptWithEdits - case .acceptAutoApprove: return .acceptAutoApprove - case .reject: return .reject - case .rejectWithFeedback: return .rejectWithFeedback(reason: reason ?? "") - } - } -} - -public struct PingPayload: Codable, Sendable { - public let t: Date - public init(t: Date = .now) { self.t = t } -} - -public struct PongPayload: Codable, Sendable { - public let t: Date - public init(t: Date = .now) { self.t = t } -} - // MARK: - Codable extension Payload: Codable { diff --git a/RxCode/App/AppState+Agents.swift b/RxCode/App/AppState+Agents.swift new file mode 100644 index 0000000..97c1c89 --- /dev/null +++ b/RxCode/App/AppState+Agents.swift @@ -0,0 +1,592 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Memory + + func allMemoryItems() async -> [MemoryItem] { + await memoryService.allMemories() + } + + func searchMemoryItems(query: String, projectId: UUID? = nil, limit: Int = 50) async -> [MemoryService.Hit] { + await memoryService.search(query, projectId: projectId, limit: limit) + } + + @discardableResult + func addMemoryItem(content: String, projectId: UUID?, kind: String = "fact", scope: String = "project") async -> MemoryItem? { + guard memoryEnabled else { return nil } + let item = await memoryService.addMemory( + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: nil, + sourceMessageId: nil, + kind: normalizedMemoryKind(kind), + scope: normalizedMemoryScope(scope) + ) + if item != nil { memoryRevision &+= 1 } + return item + } + + @discardableResult + func updateMemoryItem(id: String, content: String, projectId: UUID?, kind: String, scope: String) async -> MemoryItem? { + guard memoryEnabled else { return nil } + let item = await memoryService.updateMemory( + id: id, + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: nil, + sourceMessageId: nil, + kind: normalizedMemoryKind(kind), + scope: normalizedMemoryScope(scope) + ) + if item != nil { memoryRevision &+= 1 } + return item + } + + func deleteMemoryItem(id: String) async { + await memoryService.deleteMemory(id: id) + memoryRevision &+= 1 + } + + func deleteAllMemoryItems(projectId: UUID? = nil) async { + await memoryService.deleteAll(projectId: projectId) + memoryRevision &+= 1 + } + + func memoryContextSystemPrompt(for hits: [MemoryService.Hit]) -> String { + guard memoryEnabled, memoryInjectEnabled, !hits.isEmpty else { return "" } + let lines = hits.prefix(memoryMaxContextItems).enumerated().map { idx, hit in + "\(idx + 1). \(hit.item.content)" + }.joined(separator: "\n") + return """ + # Relevant user memory + + The notes below are durable user/project memories saved locally in RxCode. Use them as background context for this turn. They may be incomplete or stale; the current user message still has priority. + + \(lines) + """ + } + + func memoryContextPromptPrefix(for context: String, prompt: String) -> String { + let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return prompt } + return """ + \(trimmed) + + User request: + \(prompt) + """ + } + + func scheduleMemoryExtraction( + sessionId: String, + projectId: UUID, + messages: [ChatMessage] + ) { + guard memoryEnabled, memoryAutoCreateEnabled else { return } + let userMessage = lastUserMessageText(in: messages) + let finalResponse = lastAssistantResponseText(in: messages) + guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } + let sourceMessageId = messages.last(where: { $0.role == .user && !$0.isError })?.id + let summary = allSessionSummaries.first(where: { $0.id == sessionId }) + ?? summaryFor(sessionId: sessionId, projectId: projectId) + + Task { [weak self] in + guard let self else { return } + await self.extractAndStoreMemories( + sessionId: sessionId, + projectId: projectId, + sourceMessageId: sourceMessageId, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) + } + } + + func extractAndStoreMemories( + sessionId: String, + projectId: UUID, + sourceMessageId: UUID?, + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async { + let relatedHits = await memoryService.search( + "\(userMessage)\n\(finalResponse)", + projectId: projectId, + limit: 6 + ) + let related = relatedHits.map { (id: $0.item.id, content: $0.item.content) } + guard let raw = await generateMemoryOperations( + existingMemories: related, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) else { return } + let operations = Self.parseMemoryOperations(raw) + guard !operations.isEmpty else { return } + + var changed = false + for operation in operations { + switch operation.action { + case "add": + guard let content = operation.content?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty else { continue } + let scope = normalizedMemoryScope(operation.scope) + let existing = await memoryService.search(content, projectId: projectId, limit: 1) + if let best = existing.first, best.score > 0.94 { continue } + if await memoryService.addMemory( + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: normalizedMemoryKind(operation.kind), + scope: scope + ) != nil { + changed = true + } + case "update": + guard let id = operation.id, + let content = operation.content?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty else { continue } + let scope = normalizedMemoryScope(operation.scope) + if await memoryService.updateMemory( + id: id, + content: content, + projectId: scope == "global" ? nil : projectId, + sessionId: sessionId, + sourceMessageId: sourceMessageId, + kind: normalizedMemoryKind(operation.kind), + scope: scope + ) != nil { + changed = true + } + case "delete": + guard let id = operation.id else { continue } + await memoryService.deleteMemory(id: id) + changed = true + default: + continue + } + } + if changed { + memoryRevision &+= 1 + } + } + + struct MemoryOperation { + let action: String + let id: String? + let content: String? + let kind: String? + let scope: String? + } + + static func parseMemoryOperations(_ raw: String) -> [MemoryOperation] { + let trimmed = stripJSONFence(raw) + guard let range = jsonArrayRange(in: trimmed) else { return [] } + let json = String(trimmed[range]) + guard let data = json.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return [] } + return array.compactMap { entry in + guard let action = entry["action"] as? String else { return nil } + return MemoryOperation( + action: action.lowercased(), + id: entry["id"] as? String, + content: entry["content"] as? String, + kind: entry["kind"] as? String, + scope: entry["scope"] as? String + ) + } + } + + static func stripJSONFence(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("```") { + var lines = text.components(separatedBy: "\n") + if !lines.isEmpty { lines.removeFirst() } + if lines.last?.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { + lines.removeLast() + } + text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + return text + } + + static func jsonArrayRange(in text: String) -> Range? { + guard let start = text.firstIndex(of: "["), + let end = text.lastIndex(of: "]"), + start <= end else { return nil } + return start.. String { + switch value?.lowercased() { + case "preference", "decision", "fact": + return value!.lowercased() + default: + return "fact" + } + } + + func normalizedMemoryScope(_ value: String?) -> String { + value?.lowercased() == "global" ? "global" : "project" + } + + // MARK: - Agent Backends + + /// Looks up the `AgentBackend` for the given provider. Used by + /// `processStream`/`cancel`/`finalize` to dispatch via the unified + /// protocol instead of switching on the enum directly. + func backend(for provider: AgentProvider) -> any AgentBackend { + switch provider { + case .claudeCode: return claude + case .codex: return codex + case .acp: return acp + } + } + + // 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() + } + } + + 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. + 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() + } + + static func acpModelListDescription(_ options: [ACPModelOption]) -> String { + options.map { option in + option.name == option.value ? option.value : "\(option.value) (\(option.name))" + }.joined(separator: ", ") + } + + 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 + + func refreshMCPServers() async { + mcpIsLoading = true + mcpListError = nil + defer { mcpIsLoading = false } + do { + // Pass the active project so Settings can show global defaults plus + // the effective per-project override state. + let list = try await mcp.list(projectPath: activeProjectPath) + // Preserve last-known status for rows that already exist so a list + // refresh doesn't visually downgrade everything to .unknown. + var merged: [MCPServerInfo] = [] + merged.reserveCapacity(list.count) + for info in list { + if let existing = mcpServers.first(where: { $0.id == info.id }) { + merged.append(MCPServerInfo( + name: info.name, + transport: info.transport, + endpoint: info.endpoint, + status: existing.status, + scope: info.scope, + projectPath: info.projectPath, + isGloballyEnabled: info.isGloballyEnabled, + projectOverride: info.projectOverride, + effectiveEnabled: info.effectiveEnabled + )) + } else { + merged.append(info) + } + } + mcpServers = merged + } catch { + mcpListError = error.localizedDescription + logger.error("MCP list failed: \(error.localizedDescription, privacy: .public)") + } + } + + func probeMCPServer(name: String) async { + guard let info = mcpServers.first(where: { $0.name == name }) else { + // Fall back to a name-only probe if the row hasn't loaded yet. + await probeMCPServer(id: name, name: name, lookup: { await self.mcp.probe(name: name, projectPath: self.activeProjectPath) }) + return + } + await probeMCPServer(info: info) + } + + /// Probe one specific row. Use this when the same server name appears in + /// multiple scopes/projects (aggregated Settings list) so the right + /// configuration is resolved. + func probeMCPServer(info: MCPServerInfo) async { + await probeMCPServer(id: info.id, name: info.name, lookup: { await self.mcp.probe(info: info) }) + } + + func probeMCPServer(id: String, name: String, lookup: @escaping () async -> MCPProbeResult) async { + guard !mcpInFlightProbes.contains(id) else { return } + mcpInFlightProbes.insert(id) + defer { mcpInFlightProbes.remove(id) } + + let previousStatus: MCPStatus? = mcpServers.first(where: { $0.id == id })?.status + let result = await lookup() + mcpProbeResults[id] = result + + let newStatus: MCPStatus = result.ok + ? .connected + : .failed(result.error ?? "Probe failed") + + if let idx = mcpServers.firstIndex(where: { $0.id == id }) { + let row = mcpServers[idx] + mcpServers[idx] = MCPServerInfo( + name: row.name, + transport: row.transport, + endpoint: row.endpoint, + status: newStatus, + scope: row.scope, + projectPath: row.projectPath, + isGloballyEnabled: row.isGloballyEnabled, + projectOverride: row.projectOverride, + effectiveEnabled: row.effectiveEnabled + ) + } + + // 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 + { + let notifyService = NotificationService.shared + let serverName = name + Task { @MainActor in + await notifyService.postMCPDisconnected(name: serverName, error: message) + } + } + } + + @discardableResult + func addMCPServer(spec: MCPServerSpec, scope: MCPScope) async -> String? { + do { + try await mcp.add(spec: spec, scope: scope, projectPath: activeProjectPath) + await refreshMCPServers() + // Auto-probe on add so the new row shows live status and tool list + // without the user clicking Test. + await probeMCPServer(name: spec.name) + return nil + } catch { + return error.localizedDescription + } + } + + @discardableResult + func removeMCPServer(name: String, scope: MCPScope) async -> String? { + do { + try await mcp.remove(name: name, scope: scope) + // Clear the probe entry that belonged to the removed row. The id + // shape is `:[:]` — drop any whose name + // suffix and scope prefix match what we just removed. + let scopePrefix = "\(scope.rawValue):" + mcpProbeResults = mcpProbeResults.filter { key, _ in + !(key.hasPrefix(scopePrefix) && key.hasSuffix(":\(name)")) + } + await refreshMCPServers() + return nil + } catch { + return error.localizedDescription + } + } + + @discardableResult + func setMCPServerGlobalEnabled(name: String, enabled: Bool) async -> String? { + do { + try await mcp.setGlobalEnabled(name: name, enabled: enabled) + await refreshMCPServers() + return nil + } catch { + return error.localizedDescription + } + } + + @discardableResult + func setMCPServerProjectOverride(name: String, override: MCPProjectOverride) async -> String? { + guard let activeProjectPath else { + return "No active project selected." + } + do { + try await mcp.setProjectOverride(name: name, projectPath: activeProjectPath, override: override) + await refreshMCPServers() + return nil + } catch { + return error.localizedDescription + } + } + + /// Spawn the periodic MCP probe loop. Idempotent. + func startMCPPeriodicProbe() { + guard mcpPeriodicProbeTask == nil else { return } + mcpPeriodicProbeTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: AppState.mcpPeriodicProbeInterval) + guard !Task.isCancelled else { return } + await self?.refreshAndProbeAllMCPServers() + } + } + } + + /// Refresh the MCP server list and probe every server concurrently. + /// Used at app launch (and on a 5-minute timer) so the Settings sheet shows + /// live status without the user having to click "Test" on each row. + func refreshAndProbeAllMCPServers() async { + await refreshMCPServers() + let snapshot = mcpServers + guard !snapshot.isEmpty else { return } + await withTaskGroup(of: Void.self) { group in + for info in snapshot { + group.addTask { [weak self] in + await self?.probeMCPServer(info: info) + } + } + } + } + +} diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift new file mode 100644 index 0000000..2011c7e --- /dev/null +++ b/RxCode/App/AppState+CrossProject.swift @@ -0,0 +1,895 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Cross-Project Send (used by ide__send_to_thread) + + struct CrossProjectSendResult: Sendable { + let threadId: String + let projectId: UUID + let done: Bool + let assistantText: String + let error: String? + } + + enum CrossProjectSendError: Error, LocalizedError { + case unknownProject(UUID) + case unknownThread(String) + + var errorDescription: String? { + switch self { + case .unknownProject(let id): return "No project with id \(id.uuidString)" + case .unknownThread(let id): return "No thread with id \(id)" + } + } + } + + /// Send a prompt to a thread in any project. The send runs through the + /// normal `sendPrompt` pipeline via a synthetic `WindowState`, so all the + /// usual side-effects (title generation, briefing updates, persistence) + /// still fire and any UI windows currently bound to the same session see + /// the assistant tokens live via the shared `sessionStates` dictionary. + func sendCrossProject( + projectId: UUID?, + threadId: String?, + prompt: String, + agentProvider: AgentProvider? = nil, + model: String? = nil, + effort: String? = nil, + permissionMode: PermissionMode? = nil, + waitForResponse: Bool = true, + timeoutSeconds: TimeInterval = 120 + ) async throws -> CrossProjectSendResult { + // Resolve target project + thread. + let resolvedProject: Project + let resolvedThreadId: String? + + if let threadId { + guard let summary = allSessionSummaries.first(where: { $0.id == threadId }) + ?? threadStore.fetch(id: threadId).map({ $0.toSummary() }) + else { + throw CrossProjectSendError.unknownThread(threadId) + } + guard let proj = projects.first(where: { $0.id == summary.projectId }) else { + throw CrossProjectSendError.unknownProject(summary.projectId) + } + resolvedProject = proj + resolvedThreadId = threadId + } else if let projectId { + guard let proj = projects.first(where: { $0.id == projectId }) else { + throw CrossProjectSendError.unknownProject(projectId) + } + resolvedProject = proj + resolvedThreadId = nil + } else { + throw CrossProjectSendError.unknownProject(UUID()) + } + + // Build a synthetic WindowState. AppState.sessionStates is shared across + // windows, so the message + stream are visible to any real window that + // happens to also be viewing this session. + let window = WindowState() + window.selectedProject = resolvedProject + window.currentSessionId = resolvedThreadId + + // Carry over per-session overrides for a new thread; for an existing + // thread we leave the session's own stored values alone (the resume + // path in sendPrompt reads from `sessionStates[sessionKey]`). + if resolvedThreadId == nil { + if let agentProvider { + window.sessionAgentProvider = agentProvider + } + if let model { + window.sessionModel = model + } + if let effort { + window.sessionEffort = effort + } + if let permissionMode { + window.sessionPermissionMode = permissionMode + } + } + + guard let streamId = await sendPrompt(prompt, displayText: prompt, in: window) else { + return CrossProjectSendResult( + threadId: resolvedThreadId ?? "", + projectId: resolvedProject.id, + done: false, + assistantText: "", + error: "Send failed: no session could be allocated." + ) + } + + // After sendPrompt returns, window.currentSessionId is the (possibly + // pending-) key the stream is bound to. The CLI may rename it to its + // own sid mid-stream; we surface whichever id the completion lands on. + let postSendThreadId = window.currentSessionId ?? resolvedThreadId ?? "" + + if !waitForResponse { + // Don't leak the result in the dictionary — the caller is + // fire-and-forget. Drop it once it lands. + Task { [weak self] in + _ = await self?.awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) + } + return CrossProjectSendResult( + threadId: postSendThreadId, + projectId: resolvedProject.id, + done: false, + assistantText: "", + error: nil + ) + } + + let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) + if let completion { + return CrossProjectSendResult( + threadId: completion.sessionId, + projectId: resolvedProject.id, + done: completion.error == nil, + assistantText: completion.assistantText, + error: completion.error + ) + } else { + // Timed out. Surface the partial assistant text we have so far so + // the caller can decide whether to poll back via get_thread_messages. + let partial = lastAssistantResponseText(in: stateForSession(window.currentSessionId ?? "").messages) + return CrossProjectSendResult( + threadId: window.currentSessionId ?? postSendThreadId, + projectId: resolvedProject.id, + done: false, + assistantText: partial, + error: nil + ) + } + } + + /// Drop "No response requested." text blocks from the assistant message + /// at `idx`. If the message has no blocks left after the strip, remove + /// it entirely. Called at turn-finalization sites — the marker is the + /// model's response when a turn arrives without a user prompt + /// (ScheduleWakeup, hook re-entry) and reads as noise in the chat UI. + /// Strip CLI no-op meta text ("no response requested") from a message. + /// + /// `removeIfEmpty` controls whether a message left with no blocks is also + /// deleted. The normal stream path passes `true` to discard pure no-op + /// envelopes; the cancel path passes `false` so pausing a turn never makes + /// the partial assistant bubble disappear. + static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) { + guard messages.indices.contains(idx) else { return } + messages[idx].blocks.removeAll { block in + guard let text = block.text else { return false } + return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if removeIfEmpty, messages[idx].blocks.isEmpty { + messages.remove(at: idx) + } + } + + /// Wrap a branch briefing into a system-prompt section the agent can use as + /// background context. The briefing is auto-generated from earlier threads, + /// so it is framed as advisory rather than authoritative. + static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String { + let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return """ + # Current branch briefing + + The notes below are an accumulated briefing of recent work on this \ + project's current branch (`\(branch)`). They are auto-generated from \ + previous chat threads — treat them as background context for the user's \ + request, and be aware they may be incomplete or slightly out of date. + + \(trimmed) + """ + } + + func processStream( + streamId: UUID, + prompt: String, + cwd: String, + cliSessionId: String?, + internalSessionKey: String, + agentProvider: AgentProvider, + model: String?, + effort: String? = nil, + hookSettingsPath: String?, + permissionMode: PermissionMode = .default, + hookSessionMode: PermissionMode? = nil, + projectId: UUID, + window: WindowState + ) async { + // Mode used when registering a session with PermissionServer for hook auto-approve. + // When plan toggle is on, `permissionMode` is `.plan` (for the CLI flag) but the + // user's dropdown choice (e.g. `.auto`) should still drive the hook policy. + let registerMode = hookSessionMode ?? permissionMode + let streamStart = Date() + logger.info("[Stream:UI] starting processStream (cli=\(cliSessionId ?? "new"), key=\(internalSessionKey))") + + var sessionKey = internalSessionKey + + // Resolve per-backend send-request fields (MCP injection, ACP client + // spec, model split) before dispatching through the unified protocol. + var mcpClaudeConfigPath: String? = nil + var extraSystemPrompt: String? = nil + var mcpCodexOverrides: [String] = [] + var acpMCPServers: [JSONValue] = [] + var acpSpec: ACPClientSpec? = nil + var resolvedPrompt = prompt + var resolvedModel: String? = model + var resolvedSendMode: PermissionMode = permissionMode + var earlyStream: AsyncStream? = nil + + func appendExtraSystemPrompt(_ context: String) { + let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if let existing = extraSystemPrompt, !existing.isEmpty { + extraSystemPrompt = "\(existing)\n\n\(trimmed)" + } else { + extraSystemPrompt = trimmed + } + } + + let resolvedMemoryContext: String + if memoryEnabled, memoryInjectEnabled { + let hits = await memoryService.search(prompt, projectId: projectId, limit: memoryMaxContextItems) + resolvedMemoryContext = memoryContextSystemPrompt(for: hits) + } else { + resolvedMemoryContext = "" + } + + switch agentProvider { + case .claudeCode: + // Allocate a per-session IDE-MCP port so the Claude agent can call + // IDE-only tools — cross-project chat (`ide__send_to_thread`), + // thread history, running jobs, usage. The bridge is a perl + // one-liner Claude runs as the `rxcode-ide` MCP server child. + let idePort = await ideMCPServer.allocate( + sessionKey: sessionKey, + capabilities: AgentProvider.claudeCode.staticCapabilities + ) + let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } + mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge) + // Surface the accumulated briefing for the project's current branch + // to the agent as background context via `--append-system-prompt`. + if let branch = await GitHelper.currentBranch(at: cwd), + let briefing = threadStore.branchBriefingItem(projectId: projectId, branch: branch) { + extraSystemPrompt = Self.branchBriefingSystemPrompt( + branch: branch, + briefing: briefing.briefing + ) + } + appendExtraSystemPrompt(resolvedMemoryContext) + if let skillContext = await marketplace.promptContext(for: .claudeCode) { + appendExtraSystemPrompt(skillContext) + } + case .codex: + // Allocate a per-session IDE-MCP port so the Codex agent can call + // IDE-only tools — cross-project chat, thread history, running + // jobs, usage, durable memory. The bridge is a perl one-liner + // Codex runs as the `rxcode-ide` stdio MCP server child. + let idePort = await ideMCPServer.allocate( + sessionKey: sessionKey, + capabilities: AgentProvider.codex.staticCapabilities + ) + let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } + mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge) + mcpCodexOverrides += await marketplace.codexConfigOverrides() + resolvedPrompt = memoryContextPromptPrefix(for: resolvedMemoryContext, prompt: resolvedPrompt) + if let skillContext = await marketplace.promptContext(for: .codex) { + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" + } + resolvedSendMode = registerMode + case .acp: + // Allocate a per-session IDE-MCP port so the ACP agent can call + // polyfill / introspection tools. The agent's MCP child is a + // perl one-liner that bridges its stdio to our TCP listener; + // the listener stays bound to this session for its lifetime. + let idePort = await ideMCPServer.allocate( + sessionKey: sessionKey, + capabilities: AgentProvider.acp.staticCapabilities + ) + let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } + acpMCPServers = await mcp.acpMCPServers( + projectPath: cwd, + bridgeCommand: bridge + ) + resolvedPrompt = memoryContextPromptPrefix(for: resolvedMemoryContext, prompt: resolvedPrompt) + if let skillContext = await marketplace.promptContext(for: .acp) { + resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" + } + // `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 + resolvedModel = split?.model ?? model + resolvedSendMode = registerMode + if let spec = acpClients.first(where: { $0.id == resolvedClientId && $0.enabled }) { + acpSpec = spec + } else { + logger.error("[ACP] no enabled client for id=\(resolvedClientId, privacy: .public)") + earlyStream = 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() + } + } + } + + let stream: AsyncStream + if let earlyStream { + stream = earlyStream + } else { + let request = BackendSendRequest( + streamId: streamId, + prompt: resolvedPrompt, + cwd: cwd, + sessionId: cliSessionId, + model: resolvedModel, + effort: effort, + permissionMode: resolvedSendMode, + planMode: permissionMode == .plan, + hookSettingsPath: hookSettingsPath, + mcpClaudeConfigPath: mcpClaudeConfigPath, + extraSystemPrompt: extraSystemPrompt, + mcpCodexOverrides: mcpCodexOverrides, + acpMCPServers: acpMCPServers, + acpSpec: acpSpec, + clientSessionKey: sessionKey + ) + stream = await backend(for: agentProvider).send(request) + } + + startFlushTimer(for: sessionKey) + + var eventCount = 0 + var lastEventTime = Date() + + do { + for await event in stream { + eventCount += 1 + let gap = Date().timeIntervalSince(lastEventTime) + lastEventTime = Date() + + guard !Task.isCancelled else { + logger.info("[Stream:UI] task cancelled after \(eventCount) events") + break + } + + let ownsSession = stateForSession(sessionKey).activeStreamId == streamId + + 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) + if sessionKey != resultEvent.sessionId { + if let state = sessionStates.removeValue(forKey: sessionKey) { + sessionStates[resultEvent.sessionId] = state + } + sessionIdRedirect[sessionKey] = resultEvent.sessionId + sessionKey = resultEvent.sessionId + } + let msgs = stateForSession(sessionKey).messages + if !msgs.isEmpty { + await saveSession(sessionId: resultEvent.sessionId, projectId: projectId, messages: msgs) + } + } else { + logger.debug("[Stream:UI] event #\(eventCount) — stream \(streamId) no longer owns session \(sessionKey), skipping") + } + continue + } + + switch event { + case .system(let systemEvent): + logger.info("[Stream:UI] event #\(eventCount) .system (gap=\(String(format: "%.1f", gap))s)") + if let model = systemEvent.model { + updateState(sessionKey) { $0.activeModelName = model } + } + // Hook events (SessionStart, PreToolUse, etc.) carry the parent's session_id, + // not this subprocess's. Acting on them flips currentSessionId mid-stream and + // triggers MessageListView's fade-out/in — visible as a blink. + let isHookEvent = systemEvent.subtype.hasPrefix("hook_") + if let sid = systemEvent.sessionId, !isHookEvent { + await permission.registerSession(sid: sid, projectKey: cwd, mode: registerMode) + // Capture the sessionKey BEFORE the reassignment so the + // reconciler can rename the previous row in place when + // the CLI advances `session_id` mid-stream. + let previousSessionKey = sessionKey + if sessionKey != sid { + if let state = sessionStates.removeValue(forKey: previousSessionKey) { + sessionStates[sid] = state + } + renameDraftState(from: previousSessionKey, to: sid, in: window) + sessionIdRedirect[previousSessionKey] = sid + sessionKey = sid + startFlushTimer(for: sid) + + // If this is the foreground session, also update window.currentSessionId. + // Do NOT treat `currentSessionId == nil` (the new-thread page) as foreground + // for an arbitrary streaming session — that caused the UI to auto-navigate + // to a previously-detached thread whenever its CLI advanced its session_id + // (e.g. pending→real on first system event, or compact_boundary swap). + let isFg = (window.currentSessionId ?? window.newSessionKey) == previousSessionKey + if isFg { window.currentSessionId = sid } + } + + let expectedPlaceholder = "pending-\(streamId.uuidString)" + if window.pendingPlaceholderIds.contains(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 + // genuinely-recent chats with an "in 0s" updatedAt. The + // first save once content arrives will refresh updatedAt. + let replacement = ChatSession( + id: sid, + projectId: old.projectId, + title: old.title, + 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, + 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) + threadStore.renameId(from: expectedPlaceholder, to: sid) + threadStore.upsert(replacement.summary, cliSessionId: sid) + window.removePendingPlaceholder(expectedPlaceholder) + } else { + if window.pendingPlaceholderIds.contains(expectedPlaceholder) { + window.removePendingPlaceholder(expectedPlaceholder) + allSessionSummaries.removeAll { $0.id == expectedPlaceholder } + threadStore.delete(id: expectedPlaceholder) + } + + // A retry reuses the same pending session key (oldKey) with a new streamId, + // 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) { + allSessionSummaries.removeAll { $0.id == oldKey } + threadStore.delete(id: oldKey) + window.removePendingPlaceholder(oldKey) + } + + // Decide whether to rename the previous row, insert a fresh + // one, or do nothing. Renaming in place is the load-bearing + // case: it stops empty "New Session" rows from accumulating + // every time the CLI advances `session_id` mid-stream (e.g. + // after a `compact_boundary`). + if let project = projects.first(where: { $0.id == projectId }) { + let msgs = stateForSession(sessionKey).messages + let firstUser = msgs.first(where: { $0.role == .user }) + let action = SessionRowReconciler.decide( + newSid: sid, + previousKey: previousSessionKey, + existingIds: Set(allSessionSummaries.map { $0.id }), + firstUserMessageContent: firstUser?.content + ) + switch action { + case .noop: + break + case .renameInPlace(let from, let to): + if let idx = allSessionSummaries.firstIndex(where: { $0.id == from }) { + let old = allSessionSummaries[idx] + let renamed = ChatSession.Summary( + id: to, + projectId: old.projectId, + title: old.title, + createdAt: old.createdAt, + updatedAt: old.updatedAt, + isPinned: old.isPinned, + agentProvider: old.agentProvider, + model: old.model, + effort: old.effort, + permissionMode: old.permissionMode, + origin: old.origin, + worktreePath: old.worktreePath, + worktreeBranch: old.worktreeBranch, + isArchived: old.isArchived, + archivedAt: old.archivedAt + ) + allSessionSummaries.remove(at: idx) + allSessionSummaries.removeAll { $0.id == to } + allSessionSummaries.insert(renamed, at: 0) + threadStore.renameId(from: from, to: to) + threadStore.upsert(renamed, cliSessionId: to) + } + case .insertNew(let id, let title): + // Use the user-message timestamp so the row doesn't + // reorder above more recent chats while still empty. + let firstUserDate = firstUser?.timestamp ?? Date() + let inserted = ChatSession.Summary( + id: id, + projectId: project.id, + title: title, + createdAt: firstUserDate, + updatedAt: firstUserDate, + isPinned: false, + agentProvider: agentProvider, + origin: agentProvider.defaultSessionOrigin + ) + allSessionSummaries.insert(inserted, at: 0) + threadStore.upsert(inserted, cliSessionId: id) + } + } + } + if previousSessionKey != sid { + broadcastMobileSessionRedirect(from: previousSessionKey, to: sid) + } + } + + if systemEvent.subtype == "compact_boundary" { + updateState(sessionKey) { state in + state.messages.append(ChatMessage(role: .assistant, content: "Previous conversation has been compacted", isCompactBoundary: true)) + } + } + + case .assistant(let assistantMessage): + logger.debug("[Stream:UI] event #\(eventCount) .assistant (gap=\(String(format: "%.1f", gap))s, blocks=\(assistantMessage.content.count))") + if assistantMessage.content.contains(where: { + if case .thinking = $0 { return true } + return false + }) { + updateState(sessionKey) { $0.isThinking = true } + } + // A turn can contain several model invocations (one per tool round-trip); + // each emits its own `usage.output_tokens` starting from zero. Track the + // running max per message id and sum across ids to get the turn total. + if let liveOutput = assistantMessage.usage?.outputTokens { + updateState(sessionKey) { state in + if let messageId = assistantMessage.id { + let existing = state.currentTurnOutputTokensByMessage[messageId] ?? 0 + state.currentTurnOutputTokensByMessage[messageId] = max(existing, liveOutput) + } else { + state.currentTurnOutputTokensUnkeyed = max(state.currentTurnOutputTokensUnkeyed, liveOutput) + } + } + if agentProvider == .codex { + let total = stateForSession(sessionKey).currentTurnOutputTokens + logger.info("[Stream:UI] Codex usage applied messageId=\(assistantMessage.id ?? "", privacy: .public) output=\(liveOutput) total=\(total)") + } + } + // ACP-style providers deliver fully-formed tool_use blocks inside .assistant + // events (no content_block_start raw stream). Commit any buffered text first + // so tool bubbles appear after — and not in the middle of — the prior text. + let hasToolUse = assistantMessage.content.contains { + if case .toolUse = $0 { return true } + return false + } + if hasToolUse { + flushPendingUpdates(for: sessionKey) + } + + updateState(sessionKey) { state in + // Text fallback: only buffer text when no text_delta has been received in + // this turn. Normally content_block_delta(text_delta) is the primary path. + let canBufferText: Bool = { + guard state.textDeltaBuffer.isEmpty else { return false } + let afterLastUser = (state.messages.lastIndex(where: { $0.role == .user }).map { $0 + 1 }) ?? 0 + return !state.messages.suffix(from: afterLastUser).contains { + $0.role == .assistant && $0.blocks.contains(where: \.isText) + } + }() + + for block in assistantMessage.content { + switch block { + case .text(let text): + if canBufferText, !text.isEmpty { + state.textDeltaBuffer += text + } + case .toolUse(let id, let name, let input): + state.isThinking = false + // Merge updates by id: ACP agents may re-emit the same toolUse + // with additional input (e.g. diff content arriving via a + // follow-up tool_call_update). Patch the existing block in + // place so the live edit info reaches `flushPendingUpdates` + // when the result lands. + if let existingMsgIdx = state.messages.indices.reversed().first(where: { + state.messages[$0].toolCallIndex(id: id) != nil + }), + let existingBlockIdx = state.messages[existingMsgIdx].toolCallIndex(id: id) { + var merged = state.messages[existingMsgIdx].blocks[existingBlockIdx].toolCall?.input ?? [:] + for (key, value) in input { merged[key] = value } + state.messages[existingMsgIdx].blocks[existingBlockIdx].toolCall?.input = merged + } else { + if state.needsNewMessage { + if let idx = state.messages.indices.reversed().first(where: { + state.messages[$0].role == .assistant && state.messages[$0].isStreaming + }) { + state.messages[idx].isStreaming = false + state.messages[idx].finalizeToolCalls() + Self.stripNoOpText(at: idx, in: &state.messages) + } + state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) + state.needsNewMessage = false + } else if state.messages.last?.role != .assistant + || !(state.messages.last?.isStreaming ?? false) { + state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) + } + if let lastIndex = state.messages.indices.last, + state.messages[lastIndex].role == .assistant { + state.messages[lastIndex].appendToolCall(ToolCall(id: id, name: name, input: input)) + } + } + case .thinking: + state.isThinking = true + } + } + } + + case .user(let userMessage): + logger.debug("[Stream:UI] event #\(eventCount) .user (gap=\(String(format: "%.1f", gap))s, toolUseId=\(userMessage.toolUseId ?? "none"))") + updateState(sessionKey) { state in + guard let toolUseId = userMessage.toolUseId else { return } + state.pendingToolResults.append((toolUseId, userMessage.content, userMessage.isError)) + state.needsNewMessage = true + } + + case .result(let resultEvent): + logger.info("[Stream:UI] event #\(eventCount) .result (gap=\(String(format: "%.1f", gap))s, isError=\(resultEvent.isError), session=\(resultEvent.sessionId))") + + // With `--input-format stream-json` the CLI stays alive waiting for more + // input. Close stdin on `result` so it exits cleanly, then finalize so + // any subagent children that survived the parent CLI get reaped. + await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) + + if sessionKey != resultEvent.sessionId { + if let state = sessionStates.removeValue(forKey: sessionKey) { + sessionStates[resultEvent.sessionId] = state + } + sessionKey = resultEvent.sessionId + } + + // A background completion is "finished, unread". Setting the + // flag inside finalizeStreamSession means the trailing + // `.streamingFinished` broadcast already carries it to mobile. + let isFg = (window.currentSessionId ?? window.newSessionKey) == sessionKey + let markUnread = !isFg && !resultEvent.isError + + finalizeStreamSession(for: sessionKey) { state in + if let cost = resultEvent.totalCostUsd { state.costUsd = cost } + if let duration = resultEvent.durationMs { state.durationMs += duration } + if let turns = resultEvent.totalTurns { state.turns += turns } + if let usage = resultEvent.usage { + state.inputTokens += usage.inputTokens + state.outputTokens += usage.outputTokens + state.cacheCreationTokens += usage.cacheCreationInputTokens + state.cacheReadTokens += usage.cacheReadInputTokens + } + if markUnread { state.hasUncheckedCompletion = true } + } + + recordStreamCompletion( + streamId: streamId, + sessionId: resultEvent.sessionId, + assistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), + error: resultEvent.isError ? "Agent reported an error result." : nil + ) + + if isFg { + window.currentSessionId = resultEvent.sessionId + if resultEvent.isError { + let errText = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) + ?? "\(agentProvider.displayName) returned an error." + addErrorMessage(errText, in: window) + } + } + + await saveSession( + sessionId: resultEvent.sessionId, + projectId: projectId, + messages: stateForSession(sessionKey).messages + ) + + if agentProvider == .claudeCode { + reconcileFromDisk(sessionId: resultEvent.sessionId, projectId: projectId, cwd: cwd) + } + + if !resultEvent.isError { + let sid = resultEvent.sessionId + let key = sessionKey + let cwdCapture = cwd + if agentProvider == .claudeCode { + Task { [weak self] in + guard let self else { return } + if let pct = await claude.fetchContextPercentage(sessionId: sid, cwd: cwdCapture) { + updateState(key) { $0.lastTurnContextUsedPercentage = pct } + } + } + } + + if notificationsEnabled { + let summary = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId }) + let title = summary?.title ?? "New Session" + let responseText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) + let fallbackBody = responseNotificationFallback(from: responseText) + let pid = projectId + let sid = resultEvent.sessionId + let postLocalBanner = !NSApp.isActive + Task { [weak self] in + var body = fallbackBody + if let self, let summary { + body = await self.generateResponseNotificationSummary(responseText: responseText, summary: summary) ?? fallbackBody + } + await NotificationService.shared.postResponseComplete(title: title, body: body, projectId: pid, sessionId: sid, postLocalBanner: postLocalBanner) + } + } + + scheduleThreadSummaryUpdate( + sessionId: resultEvent.sessionId, + projectId: projectId, + cwd: cwd, + messages: stateForSession(sessionKey).messages + ) + scheduleMemoryExtraction( + sessionId: resultEvent.sessionId, + projectId: projectId, + messages: stateForSession(sessionKey).messages + ) + + // If this session is running in the background, automatically process any queued messages. + // Foreground sessions are handled by InputBarView via isStreaming onChange. + if !isFg { + await processBackgroundQueue(for: sessionKey, projectId: projectId, cwd: cwd, in: window) + } + } + + 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 + { + addErrorMessage("Rate limited. Retrying in \(Int(retry))s...", in: window) + } + + case .todoSnapshot(let snapshot): + let targetSession = snapshot.sessionId ?? sessionKey + let done = snapshot.items.filter { $0.status == .completed }.count + let active = snapshot.items.first(where: { $0.status == .inProgress })?.activeForm ?? "-" + logger.info( + "[TodoSnapshot] session=\(targetSession, privacy: .public) total=\(snapshot.items.count) done=\(done) active=\(active, privacy: .public)" + ) + threadStore.upsertTodoSnapshot(sessionId: targetSession, items: snapshot.items) + broadcastMobileSessionStatus(sessionID: targetSession) + + 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))") + } + handlePartialEvent(raw, for: sessionKey) + } + } + + let elapsed = Date().timeIntervalSince(streamStart) + logger.info("[Stream:UI] stream ended after \(eventCount) events, \(String(format: "%.1f", elapsed))s total") + + // Consume any remaining stderr — used as error message content below. + // If already consumed at result.isError time, this returns nil. + let stderrOutput = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) + + if eventCount == 0 { + // User cancellation revokes activeStreamId or cancels the task — distinguish + // that from a real "CLI died with no output" failure. + let wasCancelled = Task.isCancelled || stateForSession(sessionKey).activeStreamId != streamId + if !wasCancelled { + let errorMsg = stderrOutput ?? "No response received" + addErrorMessage(errorMsg, in: window) + logger.error("[Stream:UI] no events received — appending error bubble. stderr=\(stderrOutput ?? "nil")") + } else { + logger.debug("[Stream:UI] no events received — suppressed (cancelled). stderr=\(stderrOutput ?? "nil")") + } + } + + let isStillOwner = stateForSession(sessionKey).activeStreamId == streamId + let stillStreaming = stateForSession(sessionKey).isStreaming + if stillStreaming, isStillOwner { + logger.warning("[Stream:UI] isStreaming was still true at stream end — forcing cleanup") + let markUnread = (window.currentSessionId ?? window.newSessionKey) != sessionKey + finalizeStreamSession(for: sessionKey) { state in + if markUnread { state.hasUncheckedCompletion = true } + } + + // If the last assistant message is invisible after cleanup (blocks=[] because + // all tool calls had empty/nil results), show an error bubble so the user + // understands what happened rather than seeing no response at all. + let lastMsg = stateForSession(sessionKey).messages.last + if lastMsg.map({ $0.role == .assistant && $0.blocks.isEmpty }) == true { + let errorMsg = stderrOutput ?? "Response was interrupted" + updateState(sessionKey) { state in + state.messages.append(ChatMessage(role: .assistant, content: errorMsg, isError: true)) + } + } + + let msgs = stateForSession(sessionKey).messages + if !msgs.isEmpty { + await saveSession(sessionId: sessionKey, projectId: projectId, messages: msgs) + } + } 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") + finalizeStreamSession(for: sessionKey) + let msgs = stateForSession(sessionKey).messages + if !msgs.isEmpty { + await saveSession(sessionId: sessionKey, projectId: projectId, messages: msgs) + } + } else { + logger.info("[Stream:UI] stream \(streamId) ended but newer stream \(currentOwner!) owns session — skipping cleanup") + } + } + + // Fallback completion record: covers cancellations, no-events errors, + // and any path where `.result` was not received. The `.result` case + // already records a completion before reaching here — recordStreamCompletion + // is idempotent (it overwrites with the latest), but if a prior call set a + // successful completion we don't want to clobber it with an error. + if pendingStreamCompletions[streamId] == nil { + let assistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) + let errorMsg: String? = eventCount == 0 + ? (stderrOutput ?? "Stream ended with no events.") + : (Task.isCancelled ? "Stream was cancelled." : nil) + recordStreamCompletion( + streamId: streamId, + sessionId: sessionKey, + assistantText: assistantText, + error: errorMsg + ) + } + } + } + + func finalizeAgentStream(agentProvider: AgentProvider, streamId: UUID) async { + // Claude needs an explicit stdin close before finalize so the CLI + // sees EOF; other backends manage stdin internally. + if agentProvider == .claudeCode { + await claude.closeStdin(streamId: streamId) + } + await backend(for: agentProvider).finalize(streamId: streamId) + } + + /// Release the per-session IDE-MCP listener allocated for ACP turns. + /// Safe to call for non-ACP providers (no-op). + func releaseIDESession(sessionKey: String) async { + await ideMCPServer.release(sessionKey: sessionKey) + } + + func consumeAgentStderr(agentProvider: AgentProvider, streamId: UUID) async -> String? { + switch agentProvider { + case .claudeCode: + return await claude.consumeStderr(for: streamId) + case .codex: + return await codex.consumeStderr(for: streamId) + case .acp: + return await acp.consumeStderr(for: streamId) + } + } + +} diff --git a/RxCode/App/AppState+Helpers.swift b/RxCode/App/AppState+Helpers.swift new file mode 100644 index 0000000..966a2b7 --- /dev/null +++ b/RxCode/App/AppState+Helpers.swift @@ -0,0 +1,562 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Marketplace + + func loadMarketplace(forceRefresh: Bool = false) async { + marketplaceLoading = true + marketplaceSourceError = nil + defer { marketplaceLoading = false } + + async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh) + async let installed = marketplace.installedPluginNames() + + let fetchedCatalog = await catalog + let installedNames = await installed + await marketplace.importInstalledPlugins(catalog: fetchedCatalog, installedNames: installedNames) + + marketplaceCatalog = fetchedCatalog + marketplaceInstalledNames = installedNames + marketplaceCustomSources = await marketplace.customSources() + } + + func installMarketplacePlugin(_ plugin: MarketplacePlugin) async { + marketplacePluginStates[plugin.id] = .installing + do { + try await marketplace.installPlugin(plugin) + marketplacePluginStates[plugin.id] = .installed + marketplaceInstalledNames.insert(plugin.name) + } catch { + marketplacePluginStates[plugin.id] = .failed(error.localizedDescription) + logger.error("Failed to install plugin \(plugin.name): \(error.localizedDescription)") + } + } + + func uninstallMarketplacePlugin(_ plugin: MarketplacePlugin) async { + do { + try await marketplace.uninstallPlugin(plugin) + marketplaceInstalledNames.remove(plugin.name) + marketplacePluginStates[plugin.id] = .notInstalled + } catch { + logger.error("Failed to uninstall plugin \(plugin.name): \(error.localizedDescription)") + } + } + + @discardableResult + func addMarketplaceGitSource(url: String, ref: String?) async -> Bool { + marketplaceSourceError = nil + do { + _ = try await marketplace.addCustomGitSource(url: url, ref: ref) + marketplaceCustomSources = await marketplace.customSources() + await loadMarketplace(forceRefresh: true) + return true + } catch { + marketplaceSourceError = error.localizedDescription + logger.error("Failed to add marketplace Git source: \(error.localizedDescription)") + return false + } + } + + @discardableResult + func removeMarketplaceGitSource(_ source: MarketplaceCustomSource) async -> Bool { + marketplaceSourceError = nil + do { + try await marketplace.removeCustomGitSource(source) + marketplaceCustomSources = await marketplace.customSources() + await loadMarketplace(forceRefresh: true) + return true + } catch { + marketplaceSourceError = error.localizedDescription + logger.error("Failed to remove marketplace Git source: \(error.localizedDescription)") + return false + } + } + + // MARK: - Attachment Management + + func addAttachment(_ attachment: Attachment, in window: WindowState) { + window.attachments.append(attachment) + } + + func removeAttachment(_ id: UUID, in window: WindowState) { + window.removeAttachment(id) + } + + func buildPromptWithAttachments(_ text: String, attachments: [Attachment]) -> String { + guard !attachments.isEmpty else { return text } + let attachmentLines = attachments.map(\.promptContext).joined(separator: "\n") + let userText = text.isEmpty ? "See attached files" : text + return "\(attachmentLines)\n\n\(userText)" + } + + // MARK: - Private Helpers + + /// Extract the last response time from the message list. Based on the last assistant message; falls back to the last message, then current time. + func lastResponseDate(from messages: [ChatMessage]) -> Date { + messages.last(where: { $0.role == .assistant })?.timestamp + ?? messages.last?.timestamp + ?? Date() + } + + func cleanLoadedMessages(_ raw: [ChatMessage]) -> [ChatMessage] { + raw.compactMap { message in + var msg = message + msg.isStreaming = false + if msg.blocks.isEmpty, msg.role == .assistant { return nil } + return msg + } + } + + + /// Build the routing summary for `persistence.loadFullSession`. Falls back + /// to a synthesized `.cliBacked` summary when the session hasn't been + /// indexed yet (e.g. brand-new session whose `.result` arrived before the + /// summary list refresh). + func summaryFor(sessionId: String, projectId: UUID) -> ChatSession.Summary { + let fallbackProvider = defaultModelSelection(for: projects.first { $0.id == projectId }).provider + return allSessionSummaries.first(where: { $0.id == sessionId }) + ?? ChatSession.Summary( + id: sessionId, projectId: projectId, title: "", + createdAt: Date(), updatedAt: Date(), isPinned: false, + agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider, + origin: (sessionStates[sessionId]?.agentProvider ?? fallbackProvider).defaultSessionOrigin + ) + } + + /// Reload messages from the CLI's jsonl on disk and fill any blocks the + /// live stream may have missed (e.g. ownership-transfer races, observation + /// re-subscribe gaps). Fired off as a detached task so the stream loop is + /// not delayed by the mmap parse. + /// + /// Replacement is gated on disk having strictly more block content than + /// memory, so the common no-drift case produces no UI churn. Before the + /// parse, the file size is compared against the last seen size — if the + /// jsonl hasn't grown, the parse is skipped entirely. + func reconcileFromDisk(sessionId: String, projectId: UUID, cwd: String) { + let summary = summaryFor(sessionId: sessionId, projectId: projectId) + let lastSize = lastReconciledJsonlSize[sessionId] + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + let url = await self.cliStore.directory(forCwd: cwd) + .appendingPathComponent("\(sessionId).jsonl") + let currentSize: UInt64? = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int) + .flatMap(UInt64.init(exactly:)) + if let lastSize, let currentSize, currentSize <= lastSize { return } + + guard let full = await self.persistence.loadFullSession(summary: summary, cwd: cwd) else { return } + let cleaned = await self.cleanLoadedMessages(full.messages) + await MainActor.run { + guard var state = self.sessionStates[sessionId], !state.isStreaming else { return } + if let currentSize { self.lastReconciledJsonlSize[sessionId] = currentSize } + let memBlocks = state.messages.reduce(0) { $0 + $1.blocks.count } + let diskBlocks = cleaned.reduce(0) { $0 + $1.blocks.count } + guard diskBlocks > memBlocks else { return } + self.logger.info("[Reconcile] sid=\(sessionId, privacy: .public) memBlocks=\(memBlocks) diskBlocks=\(diskBlocks) — applied") + state.messages = cleaned + self.sessionStates[sessionId] = state + } + } + } + + /// Load messages in the background and inject without blocking the main thread. + /// Does not overwrite if currently streaming or if messages already exist. + /// `cwd` is needed so we can route to the CLI's jsonl when origin is `.cliBacked`. + func loadMessagesInBackground(projectId: UUID, sessionId: String, cwd: String) { + // Snapshot the summary while we're on MainActor so the detached task + // can route by origin without awaiting back to us first. + let summary = summaryFor(sessionId: sessionId, projectId: projectId) + logger.info("[LoadMessages] start sid=\(sessionId, privacy: .public) origin=\(String(describing: summary.origin), privacy: .public) cwd=\(cwd, privacy: .public)") + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + let full = await self.persistence.loadFullSession(summary: summary, cwd: cwd) + let cleaned: [ChatMessage] + if let full { + cleaned = await self.cleanLoadedMessages(full.messages) + } else { + cleaned = [] + } + await MainActor.run { + let rawCount = full?.messages.count ?? -1 + self.logger.info("[LoadMessages] loaded sid=\(sessionId, privacy: .public) rawMessages=\(rawCount) cleaned=\(cleaned.count)") + guard var state = self.sessionStates[sessionId] else { + self.logger.error("[LoadMessages] dropped — no sessionState sid=\(sessionId, privacy: .public)") + return + } + // Always clear the loading flag, even if we bail out — otherwise the UI + // would stay faded out forever on the failure / skip paths. + state.isLoadingFromDisk = false + defer { self.sessionStates[sessionId] = state } + guard let full else { + self.logger.error("[LoadMessages] no session returned by persistence sid=\(sessionId, privacy: .public) origin=\(String(describing: summary.origin), privacy: .public)") + return + } + guard !state.isStreaming, state.messages.isEmpty else { + self.logger.info("[LoadMessages] skipped apply sid=\(sessionId, privacy: .public) isStreaming=\(state.isStreaming) existingMessages=\(state.messages.count)") + return + } + state.messages = cleaned + state.planDecisionSummaries = self.threadStore.loadPlanDecisions(sessionId: sessionId) + if state.model == nil { state.model = full.model } + if state.effort == nil { state.effort = full.effort } + if state.permissionMode == nil { state.permissionMode = full.permissionMode } + self.logger.info("[LoadMessages] applied sid=\(sessionId, privacy: .public) messages=\(state.messages.count) planDecisions=\(state.planDecisionSummaries.count)") + } + } + } + + func saveCurrentSession(in window: WindowState) async { + guard let project = window.selectedProject, + let sessionId = window.currentSessionId else { return } + await saveSession( + sessionId: sessionId, + projectId: project.id, + messages: stateForSession(sessionId).messages + ) + } + + func saveSession(sessionId: String, projectId: UUID, messages: [ChatMessage]) async { + guard !messages.isEmpty else { return } + + // 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 = summary, !existing.title.isEmpty { + title = existing.title + } else { + title = ChatSession.defaultTitle + } + + let sessionModel = sessionStates[sessionId]?.model + ?? summary?.model + let sessionAgentProvider = sessionStates[sessionId]?.agentProvider + ?? summary?.agentProvider + ?? selectedAgentProvider + let sessionEffort = sessionStates[sessionId]?.effort + ?? summary?.effort + let sessionPermissionMode = sessionStates[sessionId]?.permissionMode + ?? 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, + worktreePath: summary?.worktreePath, + worktreeBranch: summary?.worktreeBranch, + isArchived: summary?.isArchived ?? false, + archivedAt: summary?.archivedAt + ) + + do { + try await persistence.saveSession(session) + } catch { + logger.error("Failed to save session \(sessionId): \(error.localizedDescription)") + } + + // Update allSessionSummaries — skipped while streaming (updated only once after completion) + let isCurrentlyStreaming = sessionStates[sessionId]?.isStreaming ?? false + if !isCurrentlyStreaming { + let summary = session.summary + withAnimation(nil) { + while allSessionSummaries.filter({ $0.id == sessionId }).count > 1, + let lastIdx = allSessionSummaries.lastIndex(where: { $0.id == sessionId }) + { + allSessionSummaries.remove(at: lastIdx) + } + if let index = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[index] = summary + } else { + allSessionSummaries.insert(summary, at: 0) + } + } + threadStore.upsert(summary) + + // Update the on-device semantic index. Skipped while streaming so + // we only embed a thread once it has settled. + let snapshot = session + Task.detached(priority: .utility) { [searchService] in + await searchService.indexThread(snapshot) + } + } + + // Update the project's lastSessionId + if let index = projects.firstIndex(where: { $0.id == projectId }) { + projects[index].lastSessionId = sessionId + do { + try await persistence.saveProjects(projects) + } catch { + logger.error("Failed to save projects: \(error.localizedDescription)") + } + } + } + + func saveDraft(in window: WindowState) { + let key = draftKey(for: window) + let trimmed = window.inputText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { window.draftTexts.removeValue(forKey: key) } + else { window.draftTexts[key] = window.inputText } + } + + func saveQueue(in window: WindowState) { + let key = queueKey(for: window) + if window.messageQueue.isEmpty { window.draftQueues.removeValue(forKey: key) } + else { window.draftQueues[key] = window.messageQueue } + } + + /// Persistence key used for both the in-memory `draftQueues` mirror and the + /// SwiftData `QueuedMessageRecord.sessionKey` column. + func queueKey(for window: WindowState) -> String { + draftKey(for: window) + } + + func draftKey(for window: WindowState) -> String { + window.currentSessionId ?? newDraftKey(for: window) + } + + func newDraftKey(for window: WindowState) -> String { + guard let projectId = window.selectedProject?.id else { return "new" } + return "new:\(projectId.uuidString)" + } + + func renameDraftState(from oldKey: String, to newKey: String, in window: WindowState) { + guard oldKey != newKey else { return } + + if let text = window.draftTexts.removeValue(forKey: oldKey), + window.draftTexts[newKey] == nil { + window.draftTexts[newKey] = text + } + + guard let queue = window.draftQueues.removeValue(forKey: oldKey) else { return } + if var existing = window.draftQueues[newKey] { + existing.append(contentsOf: queue) + window.draftQueues[newKey] = existing + } else { + window.draftQueues[newKey] = queue + } + } + + // MARK: - Message Queue (persisted) + + func enqueueMessage(text: String, attachments: [Attachment], in window: WindowState) { + let message = QueuedMessage(text: text, attachments: attachments) + window.messageQueue.append(message) + let key = queueKey(for: window) + window.draftQueues[key] = window.messageQueue + threadStore.appendQueued(sessionKey: key, message: message) + broadcastMobileSessionStatus(sessionID: key) + } + + func removeQueuedMessage(id: UUID, in window: WindowState) { + window.dequeueMessage(id: id) + saveQueue(in: window) + threadStore.removeQueued(id: id) + broadcastMobileSessionStatus(sessionID: queueKey(for: window)) + } + + /// Pops the head of the queue (the auto-flush path used when a stream ends) + /// and removes the persisted row. + func dequeueNextForFlush(in window: WindowState) -> QueuedMessage? { + guard let next = window.dequeueNext() else { return nil } + saveQueue(in: window) + threadStore.removeQueued(id: next.id) + broadcastMobileSessionStatus(sessionID: queueKey(for: window)) + return next + } + + /// Cancels any in-flight stream for the current session, removes the chosen + /// queued message, and sends it as the next user turn. + func sendQueuedNow(id: UUID, in window: WindowState) async { + guard let target = window.messageQueue.first(where: { $0.id == id }) else { return } + // Take a snapshot of remaining queue items so we can restore them after + // `cancelStreaming` clobbers `window.inputText`/`window.attachments`. + let remaining = window.messageQueue.filter { $0.id != id } + let draftText = window.inputText + let draftAttachments = window.attachments + let shouldRestoreDraft = !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !draftAttachments.isEmpty + + // Clear the queue from memory + disk first, so the cancellation path + // doesn't see the queued item we're about to send. + let key = queueKey(for: window) + window.messageQueue.removeAll() + window.draftQueues.removeValue(forKey: key) + threadStore.clearQueue(sessionKey: key) + + if isStreaming(in: window) { + await cancelStreaming(in: window) + } + + // Restore the remaining items into memory + disk. + for msg in remaining { + window.messageQueue.append(msg) + threadStore.appendQueued(sessionKey: key, message: msg) + } + if remaining.isEmpty { + window.draftQueues.removeValue(forKey: key) + } else { + window.draftQueues[key] = window.messageQueue + } + + window.inputText = target.text + window.attachments = target.attachments + await send(in: window) + if shouldRestoreDraft { + window.inputText = draftText + window.attachments = draftAttachments + } + broadcastMobileSessionStatus(sessionID: key) + } + + /// Concatenates every queued message (texts joined with a blank line, + /// attachments merged in order), cancels any in-flight stream, clears the + /// queue, then sends the combined message as a single user turn. + func sendAllQueuedAsOne(in window: WindowState) async { + guard !window.messageQueue.isEmpty else { return } + let snapshot = window.messageQueue + let draftText = window.inputText + let draftAttachments = window.attachments + let shouldRestoreDraft = !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !draftAttachments.isEmpty + + let key = queueKey(for: window) + window.messageQueue.removeAll() + window.draftQueues.removeValue(forKey: key) + threadStore.clearQueue(sessionKey: key) + + if isStreaming(in: window) { + await cancelStreaming(in: window) + } + + let combinedText = snapshot + .map(\.text) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: "\n\n") + let combinedAttachments = snapshot.flatMap(\.attachments) + + window.inputText = combinedText + window.attachments = combinedAttachments + await send(in: window) + if shouldRestoreDraft { + window.inputText = draftText + window.attachments = draftAttachments + } + broadcastMobileSessionStatus(sessionID: key) + } + + /// Sends the next queued message for a background session (one the window is not currently displaying). + /// Foreground session queues are handled by InputBarView via the isStreaming onChange handler. + func processBackgroundQueue( + for sessionKey: String, + projectId: UUID, + cwd: String, + in window: WindowState + ) async { + guard sessionStates[sessionKey]?.isStreaming != true else { return } + guard var queue = window.draftQueues[sessionKey], !queue.isEmpty else { return } + let next = queue.removeFirst() + if queue.isEmpty { window.draftQueues.removeValue(forKey: sessionKey) } + else { window.draftQueues[sessionKey] = queue } + threadStore.removeQueued(id: next.id) + + let (resolvedAttachments, tempFilePaths) = AttachmentFactory.resolvingClipboardImages(next.attachments) + let prompt = buildPromptWithAttachments(next.text, attachments: resolvedAttachments) + let displayText = next.text + let streamId = UUID() + + updateState(sessionKey) { state in + state.messages.append(ChatMessage(role: .user, content: displayText, attachments: resolvedAttachments)) + state.inFlightUserAttachments = resolvedAttachments + state.isStreaming = true + state.hasUncheckedCompletion = false + state.activeStreamId = streamId + state.streamingStartDate = Date() + state.currentTurnOutputTokensByMessage.removeAll(keepingCapacity: true) + state.currentTurnOutputTokensUnkeyed = 0 + } + + let currentPermissionMode = sessionStates[sessionKey]?.permissionMode ?? permissionMode + let projectSelection = defaultModelSelection(for: projects.first { $0.id == projectId }) + let agentProvider = sessionStates[sessionKey]?.agentProvider ?? projectSelection.provider + var hookSettingsPath: String? + if agentProvider == .claudeCode, !currentPermissionMode.skipsHookPipeline { + do { hookSettingsPath = try await permission.writeHookSettingsFile() } + catch { logger.error("Failed to write hook settings for background queue: \(error.localizedDescription)") } + } + + await permission.registerSession(sid: sessionKey, projectKey: cwd, mode: currentPermissionMode) + + let model = sessionStates[sessionKey]?.model ?? projectSelection.model + let effort = sessionStates[sessionKey]?.effort ?? (selectedEffort == "auto" ? nil : selectedEffort) + let task = Task { [weak self, window] in + guard let self else { return } + await self.processStream( + streamId: streamId, + prompt: prompt, + cwd: cwd, + cliSessionId: sessionKey, + internalSessionKey: sessionKey, + agentProvider: agentProvider, + model: model, + effort: effort, + hookSettingsPath: hookSettingsPath, + permissionMode: currentPermissionMode, + projectId: projectId, + window: window + ) + for path in tempFilePaths { + try? FileManager.default.removeItem(atPath: path) + } + } + sessionStates[sessionKey]?.streamTask = task + } + + func handleError(_ error: Error, in window: WindowState) { + logger.error("AppState error: \(error.localizedDescription)") + addErrorMessage(error.localizedDescription, in: window) + } + + func addErrorMessage(_ text: String, in window: WindowState) { + let key = window.currentSessionId ?? window.newSessionKey + let msg = ChatMessage(role: .assistant, content: text, isError: true) + updateState(key) { $0.messages.append(msg) } + } + + // MARK: - Claude Settings Reader + + nonisolated static func readPermissionModeFromSettings() -> PermissionMode { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + if let data = try? Data(contentsOf: url), + 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) + { + return parsed + } + if let saved = UserDefaults.standard.string(forKey: "selectedPermissionMode"), + let parsed = PermissionMode(rawValue: saved) + { + return parsed + } + return .default + } +} diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift new file mode 100644 index 0000000..d088822 --- /dev/null +++ b/RxCode/App/AppState+Lifecycle.swift @@ -0,0 +1,482 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Private State + + // MARK: - Window-Scoped Session State Accessors + + func streamState(in window: WindowState) -> SessionStreamState { + sessionStates[window.currentSessionId ?? window.newSessionKey] ?? SessionStreamState() + } + + func messages(in window: WindowState) -> [ChatMessage] { + streamState(in: window).messages + } + + /// File edits accumulated across this thread, sourced from SwiftData. + /// Returns an empty array for a not-yet-persisted (placeholder) session. + func threadFileEdits(in window: WindowState) -> [FileEditSummary] { + let key = window.currentSessionId ?? window.newSessionKey + return threadStore.fetchFileEdits(sessionId: key).map { $0.toSummary() } + } + + func isStreaming(in window: WindowState) -> Bool { + streamState(in: window).isStreaming + } + + func isThinking(in window: WindowState) -> Bool { + streamState(in: window).isThinking + } + + func streamingStartDate(in window: WindowState) -> Date? { + streamState(in: window).streamingStartDate + } + + func activeModelName(in window: WindowState) -> String? { + streamState(in: window).activeModelName + } + + func lastTurnContextUsedPercentage(in window: WindowState) -> Double? { + streamState(in: window).lastTurnContextUsedPercentage + } + + func sessionCostUsd(in window: WindowState) -> Double { + streamState(in: window).costUsd + } + + func sessionTurns(in window: WindowState) -> Int { + streamState(in: window).turns + } + + func sessionInputTokens(in window: WindowState) -> Int { + streamState(in: window).inputTokens + } + + func sessionOutputTokens(in window: WindowState) -> Int { + streamState(in: window).outputTokens + } + + func sessionCacheCreationTokens(in window: WindowState) -> Int { + streamState(in: window).cacheCreationTokens + } + + func sessionCacheReadTokens(in window: WindowState) -> Int { + streamState(in: window).cacheReadTokens + } + + func sessionDurationMs(in window: WindowState) -> Double { + streamState(in: window).durationMs + } + + func currentSession(in window: WindowState) -> ChatSession? { + guard let id = window.currentSessionId else { return nil } + guard let summary = allSessionSummaries.first(where: { $0.id == id }) else { return nil } + return summary.makeSession() + } + + /// Check whether a given session is streaming in the background (not foreground) of this window + func isBackgroundStreaming(_ sessionId: String, in window: WindowState) -> Bool { + guard sessionId != (window.currentSessionId ?? window.newSessionKey) else { return false } + return sessionStates[sessionId]?.isStreaming ?? false + } + + /// Returns the set of session IDs currently streaming in the background of this window. + func backgroundStreamingSessionIds(in window: WindowState) -> Set { + let currentKey = window.currentSessionId ?? window.newSessionKey + return Set(sessionStates.compactMap { key, state in + (state.isStreaming && key != currentKey) ? key : nil + }) + } + + /// Derive a UI status for the chat row in the project sidebar. + func chatStatus(forSessionId id: String, in window: WindowState) -> ChatStatus { + if window.pendingPermissions.contains(where: { $0.sessionId == id }) { + return .awaitingPermission + } + if let state = sessionStates[id] { + if state.isStreaming { return .streaming } + if state.hasUncheckedCompletion { return .done } + } + return .idle + } + + func todoProgress(forSessionId id: String) -> ChatTodoProgress? { + if let messages = sessionStates[id]?.messages, + let todos = TodoExtractor.latest(in: messages) + { + return ChatTodoProgress(todos: todos) + } + + guard let snapshot = threadStore.fetchTodoSnapshot(sessionId: id), snapshot.total > 0 else { + return nil + } + + return ChatTodoProgress( + done: snapshot.done, + total: snapshot.total, + inProgress: snapshot.inProgress > 0 + ) + } + + // MARK: - Initialization + + /// Once per app launch — start services and load shared data + func initialize() async { + ThemeStore.shared.current = selectedTheme + ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment + ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment + + // Supply MobileSyncService with desktop-side context for the mobile + // job Live Activity and home-screen widget pushes. + MobileSyncService.shared.projectNameResolver = { [weak self] id in + self?.projects.first { $0.id == id }?.name + } + MobileSyncService.shared.usageSnapshotProvider = { [weak self] in + (self?.latestRateLimitUsage?.fiveHourPercent, + self?.latestCodexRateLimitUsage?.fiveHourPercent) + } + + await refreshAgentInstallations() + + projects = await persistence.loadProjects() + var seenPaths = Set() + let deduplicated = projects.filter { seenPaths.insert($0.path).inserted } + if deduplicated.count != projects.count { + projects = deduplicated + try? await persistence.saveProjects(projects) + } + + if let cachedUser = await persistence.loadGitHubUser() { + gitHubUser = cachedUser + isLoggedIn = true + _ = await github.loadToken() + } + + customRepos = await persistence.loadCustomRepos() + marketplaceCustomSources = await marketplace.customSources() + + // Sidebar threads are now sourced from the local SwiftData store. + // CLI session files are no longer surfaced in the sidebar list — the + // CLI is still the transcript backend (replay on thread open), but + // it does not drive thread discovery. + allSessionSummaries = threadStore.loadAllSummaries() + autoArchiveExpiredSessionsIfNeeded() + purgeStaleBranchBriefingsIfNeeded() + + persistedQueues = threadStore.loadAllQueues() + + 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 { + try await permission.start() + } catch { + logger.error("Failed to start permission server: \(error.localizedDescription)") + } + + // Warm MCP server statuses in the background so the Settings sheet + // shows live connection results without the user clicking "Test". + Task { [weak self] in + await self?.refreshAndProbeAllMCPServers() + } + + // Warm the rate-limit usage so the menu-bar label has data before the + // popover is opened. RateLimitService caches for 5 minutes internally. + Task { [weak self] in + await self?.refreshRateLimitUsage() + await self?.refreshCodexRateLimitUsage() + } + + // Recurring probe so disconnected MCP servers surface promptly even + // when the user isn't actively interacting with the Settings tab. + startMCPPeriodicProbe() + + // Permission request routing is handled per-window in initializeWindow's listener. + + isInitialized = true + } + + func refreshAgentInstallations() async { + let claudeBinary = await claude.findClaudeBinary() + claudeBinaryPath = claudeBinary + claudeInstalled = claudeBinary != nil + claudeVersion = nil + + if claudeBinary != nil { + do { + claudeVersion = try await claude.checkVersion() + } catch { + logger.warning("Failed to fetch Claude CLI version: \(error.localizedDescription)") + } + } + + let codexBinary = await codex.findCodexBinary() + codexBinaryPath = codexBinary + codexInstalled = codexBinary != nil + codexVersion = nil + + if codexBinary != nil { + do { + codexVersion = try await codex.checkVersion() + codexModels = await codex.fetchModels() + logger.info("Codex CLI detected; fetched \(self.codexModels.count) Codex models") + if codexModels.isEmpty { + logger.warning("Codex model discovery returned empty; using built-in Codex fallback models") + } + Task { [weak self] in + await self?.refreshCodexRateLimitUsage() + } + } catch { + logger.warning("Failed to fetch Codex CLI version or models: \(error.localizedDescription)") + } + } else { + codexModels = [] + logger.info("Codex CLI not detected; Codex model list cleared") + } + } + + func refreshOpenAISummarizationModels() async { + let endpoint = openAISummarizationEndpoint + let apiKey = openAISummarizationAPIKey + + isLoadingOpenAISummarizationModels = true + openAISummarizationModelsError = nil + defer { isLoadingOpenAISummarizationModels = false } + + do { + let models = try await openAISummarization.fetchModels(endpoint: endpoint, apiKey: apiKey) + openAISummarizationModels = models + if openAISummarizationModel.isEmpty || !models.contains(openAISummarizationModel) { + openAISummarizationModel = models.first ?? "" + } + } catch { + openAISummarizationModelsError = error.localizedDescription + logger.warning("Failed to fetch OpenAI summarization models: \(error.localizedDescription)") + } + } + + /// Per-window initialization — restore selected project and load session history + func initializeWindow(_ window: WindowState, selectingProjectId: UUID? = nil) async { + // Subscribe to permission broadcasts — appends requests to this window's pendingPermissions. + // subscribe() issues a window-exclusive stream, so events are not stolen across multiple windows. + Task { [weak self, weak window] in + guard let self else { return } + let (_, stream) = await self.permission.subscribe() + for await request in stream { + guard !Task.isCancelled else { break } + guard let window else { break } + if !window.pendingPermissions.contains(where: { $0.id == request.id }) { + window.pendingPermissions.append(request) + mobilePendingRequests[request.id] = request + let projectName = window.selectedProject?.name + let projectId = window.selectedProject?.id + let sessionId = window.currentSessionId + let toolName = request.toolName + if let requestSessionId = request.sessionId { + broadcastMobileSessionStatus(sessionID: requestSessionId) + } + if toolName == "AskUserQuestion" { + broadcastMobileQuestionQueue() + } + // Auto-present the question sheet only when the user is actively viewing + // the thread the question belongs to. Otherwise it stays in the queue + // (yellow dot in sidebar + banner) so the user can decide when to answer. + if toolName == "AskUserQuestion", + window.presentedPermissionId == nil, + let qSession = request.sessionId, + qSession == window.currentSessionId + { + window.presentedPermissionId = request.id + } + Task { @MainActor in + if toolName == "AskUserQuestion" { + await NotificationService.shared.postQuestionNeeded( + projectName: projectName, + projectId: projectId, + sessionId: sessionId + ) + } else { + await NotificationService.shared.postPermissionNeeded( + toolName: toolName, + projectName: projectName, + projectId: projectId, + sessionId: sessionId + ) + } + } + } + } + } + + // Install the AskUserQuestion handlers. The question sheet calls submit when the + // user finishes answering, and skip when they dismiss without answering. + window.submitQuestionAnswersHandler = { [weak self, weak window] toolUseId, answers in + guard let self, let window else { return } + Task { await self.respondToAskUserQuestion(toolUseId: toolUseId, answers: answers, in: window) } + } + window.skipQuestionHandler = { [weak self, weak window] toolUseId in + guard let self, let window else { return } + Task { await self.skipAskUserQuestion(toolUseId: toolUseId, in: window) } + } + + // Install the plan-card decision handler. The buttons on `PlanCardView` route + // through here to resolve the ExitPlanMode hook and apply any follow-up mode change. + window.planDecisionHandler = { [weak self, weak window] toolUseId, action in + guard let self, let window else { return } + Task { await self.respondToPlanDecision(toolUseId: toolUseId, action: action, in: window) } + } + + // Hydrate per-window draft queues from disk-persisted queues so messages + // typed-while-streaming survive an app relaunch. + for (key, queue) in persistedQueues where window.draftQueues[key] == nil { + window.draftQueues[key] = queue + } + + if let projectId = selectingProjectId, + 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 }) + { + selectProject(project, in: window) + } else if let first = projects.first { + selectProject(first, in: window) + } + + // Show the briefing as the landing view on launch, even after restoring a project. + // `selectProject` clears `showingBriefing` for normal switches; re-enable here so the + // user lands on the briefing dashboard rather than a fresh chat. + window.showingBriefing = true + + window.isInitialized = true + } + + // MARK: - ChatBridge Setup + + /// Configures a `ChatBridge`'s action handlers and starts an observation loop that keeps + /// the bridge's state properties in sync with the underlying `sessionStates`. + func setupChatBridge(_ bridge: ChatBridge, for window: WindowState) { + registerLiveWindow(window) + bridge.sendHandler = { [weak self, weak window] in + guard let self, let window else { return } + await self.send(in: window) + } + bridge.cancelStreamingHandler = { [weak self, weak window] in + guard let self, let window else { return } + await self.cancelStreaming(in: window) + } + bridge.sendSlashCommandHandler = { [weak self, weak window] command in + guard let self, let window else { return } + await self.sendSlashCommand(command, in: window) + } + bridge.runTerminalCommandHandler = { [weak self, weak window] command in + guard let self, let window else { return } + await self.runTerminalCommand(command, in: window) + } + bridge.editAndResendHandler = { [weak self, weak window] messageId, newContent in + guard let self, let window else { return } + await self.editAndResend(messageId: messageId, newContent: newContent, in: window) + } + bridge.fetchRateLimitHandler = { [weak self] provider in + await self?.rateLimitUsage(for: provider) + } + bridge.setSessionProviderHandler = { [weak self, weak window] provider in + guard let self, let window else { return } + self.setSessionProvider(provider, in: window) + } + bridge.togglePlanModeHandler = { [weak self, weak window] in + guard let self, let window else { return } + self.toggleSessionPlanMode(in: window) + } + bridge.enqueueMessageHandler = { [weak self, weak window] text, attachments in + guard let self, let window else { return } + self.enqueueMessage(text: text, attachments: attachments, in: window) + } + bridge.removeQueuedMessageHandler = { [weak self, weak window] id in + guard let self, let window else { return } + self.removeQueuedMessage(id: id, in: window) + } + bridge.dequeueNextForFlushHandler = { [weak self, weak window] in + guard let self, let window else { return nil } + return self.dequeueNextForFlush(in: window) + } + bridge.sendQueuedNowHandler = { [weak self, weak window] id in + guard let self, let window else { return } + await self.sendQueuedNow(id: id, in: window) + } + bridge.sendAllQueuedAsOneHandler = { [weak self, weak window] in + guard let self, let window else { return } + await self.sendAllQueuedAsOne(in: window) + } + + startBridgeObservation(bridge, for: window) + } + + /// Runs a reactive observation loop: reads AppState + WindowState properties into the bridge, + /// then re-registers after each change. Stops when the bridge or window is deallocated. + func startBridgeObservation(_ bridge: ChatBridge, for window: WindowState) { + // Streaming state and global settings are observed in separate loops so that frequent + // streaming updates don't trigger settings re-pushes (and vice versa). + func observeStream() { + withObservationTracking { + let state = streamState(in: window) + if bridge.messages.count != state.messages.count || bridge.isLoadingFromDisk != state.isLoadingFromDisk { + self.logger.info("[Bridge.observe] push sid=\(window.currentSessionId ?? "", privacy: .public) messages \(bridge.messages.count)→\(state.messages.count) loading \(bridge.isLoadingFromDisk)→\(state.isLoadingFromDisk) streaming=\(state.isStreaming)") + } + bridge.messages = state.messages + bridge.isStreaming = state.isStreaming + bridge.isThinking = state.isThinking + bridge.isLoadingFromDisk = state.isLoadingFromDisk + bridge.streamingStartDate = state.streamingStartDate + bridge.liveOutputTokens = state.currentTurnOutputTokens + bridge.lastTurnContextUsedPercentage = state.lastTurnContextUsedPercentage + let selection = effectiveModelSelection(in: window) + let provider = selection.provider + let currentModel = selection.model + bridge.agentProvider = provider + bridge.modelDisplayName = modelDisplayName(for: currentModel, provider: provider, in: window) + bridge.sessionStats = ChatSessionStats( + costUsd: state.costUsd, + inputTokens: state.inputTokens, + outputTokens: state.outputTokens, + cacheCreationTokens: state.cacheCreationTokens, + cacheReadTokens: state.cacheReadTokens, + durationMs: state.durationMs, + turns: state.turns + ) + bridge.planDecisionSummaries = state.planDecisionSummaries + } onChange: { + Task { @MainActor in observeStream() } + } + } + func observeSettings() { + withObservationTracking { + bridge.autoPreviewSettings = self.autoPreviewSettings + bridge.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + bridge.claudeVersion = self.claudeVersion + bridge.codexVersion = self.codexVersion + } onChange: { + Task { @MainActor in observeSettings() } + } + } + Task { @MainActor in observeStream() } + Task { @MainActor in observeSettings() } + } + +} diff --git a/RxCode/App/AppState+Messaging.swift b/RxCode/App/AppState+Messaging.swift new file mode 100644 index 0000000..871bf68 --- /dev/null +++ b/RxCode/App/AppState+Messaging.swift @@ -0,0 +1,501 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Edit & Resend + + func editAndResend(messageId: UUID, newContent: String, in window: WindowState) async { + let key = window.currentSessionId ?? window.newSessionKey + var snapshot = sessionStates[key]?.messages ?? [] + guard let index = snapshot.firstIndex(where: { $0.id == messageId }), + snapshot[index].role == .user else { return } + + let trimmed = newContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + if isStreaming(in: window) { + await cancelStreaming(in: window) + } + + snapshot.removeSubrange((index + 1)...) + snapshot[index].content = trimmed + + window.currentSessionId = nil + sessionStates.removeValue(forKey: window.newSessionKey) + await sendPrompt(trimmed, skipAppendingUserMessage: true, initialMessages: snapshot, in: window) + } + + // MARK: - Send Message + + func send(in window: WindowState) async { + let prompt = window.inputText.trimmingCharacters(in: .whitespacesAndNewlines) + let currentAttachments = window.attachments + guard !prompt.isEmpty || !currentAttachments.isEmpty else { return } + + // S2: warn (in logs) if another process touched the same jsonl very + // recently — likely a `claude` running in the terminal on the same + // session. We don't block, but the operator can spot it after the fact. + if effectiveModelSelection(in: window).provider == .claudeCode, + let sid = window.currentSessionId, + let cwd = window.selectedProject?.path, + 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") + } + + if currentAttachments.isEmpty, await handleNativeSlashCommand(prompt, in: window) { + window.inputText = "" + return + } + + window.inputText = "" + window.draftTexts.removeValue(forKey: draftKey(for: window)) + window.attachments = [] + + let (resolvedAttachments, tempFilePaths) = AttachmentFactory.resolvingClipboardImages(currentAttachments) + let fullPrompt = buildPromptWithAttachments(prompt, attachments: resolvedAttachments) + + await sendPrompt(fullPrompt, displayText: prompt, attachments: resolvedAttachments, + tempFilePaths: tempFilePaths, in: window) + } + + /// Slash commands handled natively. Returns true if handled. + func handleNativeSlashCommand(_ text: String, in window: WindowState) async -> Bool { + guard text.hasPrefix("/") else { return false } + let parts = text.split(separator: " ", maxSplits: 1) + let command = parts.first.map { String($0.dropFirst()) } ?? "" + + switch command { + case "clear": + startNewChat(in: window) + return true + case "model": + if parts.count > 1 { + let arg = String(parts[1]).trimmingCharacters(in: .whitespaces).lowercased() + let flattened = availableAgentModelSections().flatMap(\.models) + let matched = flattened.first { $0.id.lowercased() == arg } + ?? flattened.first { arg.contains($0.id.lowercased()) } + setSessionModel(matched?.id ?? arg, provider: matched?.provider, in: window) + } else { + window.showModelPicker = true + } + return true + case "effort": + if parts.count > 1 { + let arg = String(parts[1]).trimmingCharacters(in: .whitespaces).lowercased() + setSessionEffort(Self.availableEfforts.contains(arg) ? arg : nil, in: window) + } else { + window.showEffortPicker = true + } + return true + default: + return false + } + } + + // MARK: - Send Slash Command + + func sendSlashCommand(_ command: String, in window: WindowState) async { + let trimmed = command.trimmingCharacters(in: .whitespaces) + if await handleNativeSlashCommand(trimmed, in: window) { return } + + let baseName = trimmed.split(separator: " ", maxSplits: 1) + .first.map { String($0.dropFirst()) } ?? "" + + let isInteractive = SlashCommandRegistry.commands + .first { $0.name == baseName }?.isInteractive ?? false + + if isInteractive { + await sendInteractiveCommand(trimmed, in: window) + } else { + await sendPrompt(trimmed, in: window) + } + } + + func sendInteractiveCommand(_ command: String, in window: WindowState) async { + let title = command.trimmingCharacters(in: .whitespaces) + await launchTerminal(title: title, initialCommand: command, in: window) + } + + func runTerminalCommand(_ command: String, in window: WindowState) async { + let title = command.trimmingCharacters(in: .whitespaces) + await launchTerminal(title: title, initialCommand: command, rawShell: true, in: window) + } + + func openTerminal(in window: WindowState) async { + // Right sidebar visibility lives in UserDefaults (read via @AppStorage + // in views). Writing here triggers those views to update. + let defaults = UserDefaults.standard + let key = AppStorageKeys.showRightSidebar + if defaults.bool(forKey: key), window.inspectorTab == .terminal { + defaults.set(false, forKey: key) + } else { + window.inspectorTab = .terminal + defaults.set(true, forKey: key) + } + } + + func launchTerminal( + title: String, + initialCommand: String? = nil, + reportToChat: Bool = true, + rawShell: Bool = false, + in window: WindowState + ) async { + guard let project = window.selectedProject else { + handleError(AppError.noProjectSelected, in: window) + return + } + + let arguments: [String] + if rawShell { + arguments = ["-il"] + } else { + guard let binary = await claude.findClaudeBinary() else { + handleError(AppError.claudeNotInstalled, in: window) + return + } + arguments = ["-ilc", binary] + } + + window.interactiveTerminal = InteractiveTerminalState( + title: title, + executable: "/bin/zsh", + arguments: arguments, + currentDirectory: project.path, + initialCommand: initialCommand, + reportToChat: reportToChat + ) + } + + func dismissInteractiveTerminal(exitCode: Int32, in window: WindowState) { + guard let terminal = window.interactiveTerminal else { return } + window.interactiveTerminal = nil + + guard terminal.reportToChat else { return } + + let key = window.currentSessionId ?? window.newSessionKey + let wasFirstUserMessage = (sessionStates[key]?.messages.filter { $0.role == .user }.count ?? 0) == 0 + updateState(key) { state in + state.messages.append(ChatMessage(role: .user, content: terminal.title)) + let result = exitCode == 0 ? "Done" : "exit code: \(exitCode)" + let toolCall = ToolCall( + id: UUID().uuidString, + name: InteractiveTerminalState.toolName, + input: ["command": .string(terminal.title)], + result: result, + isError: exitCode != 0 + ) + state.messages.append(ChatMessage(role: .assistant, blocks: [.toolCall(toolCall)])) + } + if wasFirstUserMessage, !(sessionStates[key]?.titleGenerationTriggered ?? false) { + updateState(key) { $0.titleGenerationTriggered = true } + Task { [weak self] in + guard let self else { return } + await self.maybeGenerateLLMTitle(for: key) + } + } + Task { await saveCurrentSession(in: window) } + } + + // MARK: - Shared Send Logic + + @discardableResult + func sendPrompt( + _ prompt: String, + displayText: String? = nil, + attachments: [Attachment] = [], + skipAppendingUserMessage: Bool = false, + initialMessages: [ChatMessage]? = nil, + tempFilePaths: [String] = [], + in window: WindowState + ) async -> UUID? { + guard let project = window.selectedProject else { + handleError(AppError.noProjectSelected, in: window) + return nil + } + + if isStreaming(in: window) { + await cancelStreaming(in: window) + } + + let streamId = UUID() + let isNewSession = window.currentSessionId == nil + let isPending = window.currentSessionId.map { window.pendingPlaceholderIds.contains($0) } ?? false + let cliSessionId: String? = (isNewSession || isPending) ? nil : window.currentSessionId + + if isNewSession { + let tempId = "pending-\(streamId.uuidString)" + window.currentSessionId = tempId + window.insertPendingPlaceholder(tempId) + let snapSelection = effectiveModelSelection(in: window) + let snapProvider = snapSelection.provider + let snapModel = snapSelection.model + window.sessionAgentProvider = snapProvider + window.sessionModel = snapModel + let snapEffort = window.sessionEffort + let snapPermission = window.sessionPermissionMode + let pendingWorktreePath = window.pendingWorktreePath + let pendingWorktreeBranch = window.pendingWorktreeBranch + updateState(tempId) { state in + state.agentProvider = snapProvider + state.model = snapModel + state.effort = snapEffort + state.permissionMode = snapPermission + state.worktreePath = pendingWorktreePath + state.worktreeBranch = pendingWorktreeBranch + } + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil + } + + let sessionKey = window.currentSessionId! + + // Apply initialMessages if provided + if let initial = initialMessages { + updateState(sessionKey) { $0.messages = initial } + } + + let wasFirstUserMessage = (sessionStates[sessionKey]?.messages.filter { $0.role == .user }.count ?? 0) == 0 + if !skipAppendingUserMessage { + updateState(sessionKey) { state in + state.messages.append(ChatMessage( + role: .user, + content: displayText ?? prompt, + attachments: attachments + )) + state.inFlightUserAttachments = attachments + } + } + + // Insert the placeholder summary before kicking off title generation — + // the Task below awaits and the lookup in maybeGenerateLLMTitle would + // otherwise race the insertion at line ~1168 and bail with "no summary". + if isNewSession { + let initialTitle = ChatSession.placeholderTitle(from: displayText ?? prompt) + let selection = effectiveModelSelection(in: window) + let provider = selection.provider + let placeholder = ChatSession( + id: sessionKey, + projectId: project.id, + title: initialTitle, + messages: [], + agentProvider: provider, + model: selection.model, + origin: provider.defaultSessionOrigin, + worktreePath: sessionStates[sessionKey]?.worktreePath, + worktreeBranch: sessionStates[sessionKey]?.worktreeBranch + ) + allSessionSummaries.insert(placeholder.summary, at: 0) + threadStore.upsert(placeholder.summary) + } + + // Kick off LLM title generation as soon as the first user message lands — + // the rename runs concurrently with the stream so the sidebar title updates + // without waiting for the assistant to reply. + if wasFirstUserMessage, + !skipAppendingUserMessage, + !(sessionStates[sessionKey]?.titleGenerationTriggered ?? false) + { + updateState(sessionKey) { $0.titleGenerationTriggered = true } + let titleKey = sessionKey + Task { [weak self] in + guard let self else { return } + await self.maybeGenerateLLMTitle(for: titleKey) + } + } + + updateState(sessionKey) { state in + state.isStreaming = true + state.hasUncheckedCompletion = false + state.activeStreamId = streamId + state.streamingStartDate = Date() + state.currentTurnOutputTokensByMessage.removeAll(keepingCapacity: true) + state.currentTurnOutputTokensUnkeyed = 0 + } + broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingStarted) + + let basePermissionMode = window.sessionPermissionMode ?? permissionMode + // Plan-mode boolean overrides the dropdown for the CLI `--permission-mode` flag only. + // The dropdown choice is preserved and re-applied automatically once plan-mode is toggled back off. + let cliPermissionMode: PermissionMode = window.sessionPlanMode ? .plan : basePermissionMode + // PermissionServer registration uses the dropdown value directly so an explicit Auto + // choice continues to auto-approve hook-matched tools while plan mode is on. + // ExitPlanMode is always exempt from auto-approve (see PermissionServer.autoApproveReason), + // so the plan card still surfaces. + let hookSessionMode = basePermissionMode + let launchAgentProvider = sessionStates[sessionKey]?.agentProvider + ?? window.sessionAgentProvider + ?? selectedAgentProvider + var hookSettingsPath: String? + if launchAgentProvider == .claudeCode, !cliPermissionMode.skipsHookPipeline { + do { + hookSettingsPath = try await permission.writeHookSettingsFile() + } catch { + logger.error("Failed to write hook settings: \(error.localizedDescription)") + } + } + + // Resume already has the sid; new sessions register on first system event. + if let sid = cliSessionId { + await permission.registerSession(sid: sid, projectKey: project.path, mode: hookSessionMode) + } + + if !isNewSession { + await saveCurrentSession(in: window) + } + + let effectiveCwd = sessionStates[sessionKey]?.worktreePath + ?? allSessionSummaries.first(where: { $0.id == sessionKey })?.worktreePath + ?? project.path + let selection = effectiveModelSelection(in: window) + let effectiveProvider = sessionStates[sessionKey]?.agentProvider ?? selection.provider + let effectiveModel = sessionStates[sessionKey]?.model ?? selection.model + + let task = Task { [weak self, window] in + guard let self else { return } + await self.processStream( + streamId: streamId, + prompt: prompt, + cwd: effectiveCwd, + cliSessionId: cliSessionId, + internalSessionKey: sessionKey, + agentProvider: effectiveProvider, + model: effectiveModel, + effort: window.sessionEffort ?? (self.selectedEffort == "auto" ? nil : self.selectedEffort), + hookSettingsPath: hookSettingsPath, + permissionMode: cliPermissionMode, + hookSessionMode: hookSessionMode, + projectId: project.id, + window: window + ) + for path in tempFilePaths { + try? FileManager.default.removeItem(atPath: path) + } + } + sessionStates[sessionKey, default: SessionStreamState()].streamTask = task + return streamId + } + + // MARK: - Stream Processing + + func stateForSession(_ key: String) -> SessionStreamState { + sessionStates[key] ?? SessionStreamState() + } + + func updateState(_ key: String, _ mutate: (inout SessionStreamState) -> Void) { + let prevMessages = sessionStates[key]?.messages ?? [] + let prevThinking = sessionStates[key]?.isThinking ?? false + guard var state = sessionStates[key] else { + var fresh = SessionStreamState() + mutate(&fresh) + sessionStates[key] = fresh + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: fresh.messages, isStreaming: fresh.isStreaming) + broadcastMobileThinkingChange(sessionKey: key, prev: prevThinking, next: fresh.isThinking, isStreaming: fresh.isStreaming) + return + } + mutate(&state) + sessionStates[key] = state + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) + broadcastMobileThinkingChange(sessionKey: key, prev: prevThinking, next: state.isThinking, isStreaming: state.isStreaming) + } + + /// Mirror `isThinking` transitions to paired mobile devices so the remote + /// streaming indicator can show a "Thinking…" label. Only fires on an + /// actual change — the flag flips repeatedly within a turn and we don't + /// want to flood the relay with redundant updates. + func broadcastMobileThinkingChange(sessionKey: String, prev: Bool, next: Bool, isStreaming: Bool) { + guard prev != next, !MobileSyncService.shared.pairedDevices.isEmpty else { return } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionKey, + kind: .statusChanged, + message: nil, + isStreaming: isStreaming, + isThinking: next + ) + } + + func finalizeStreamSession( + for sessionKey: String, + extraMutations: ((inout SessionStreamState) -> Void)? = nil + ) { + flushPendingUpdates(for: sessionKey) + updateState(sessionKey) { state in + state.flushTask?.cancel() + state.flushTask = nil + state.isStreaming = false + state.isThinking = false + state.needsNewMessage = false + state.activeStreamId = nil + state.streamTask = nil + state.activeToolId = nil + state.activeToolInputBuffer = "" + state.textDeltaBuffer = "" + state.pendingToolResults.removeAll() + + extraMutations?(&state) + + if let idx = state.messages.indices.reversed().first(where: { + state.messages[$0].role == .assistant && state.messages[$0].isStreaming + }) { + state.messages[idx].isStreaming = false + state.messages[idx].isResponseComplete = true + state.messages[idx].finalizeToolCalls() + if let start = state.streamingStartDate { + state.messages[idx].duration = Date().timeIntervalSince(start) + } + Self.stripNoOpText(at: idx, in: &state.messages) + } + state.streamingStartDate = nil + } + broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingFinished) + Task { @MainActor [weak self] in + await self?.flushNextQueuedMessageIfNeeded(sessionID: sessionKey) + } + } + + // MARK: - Stream Completion (cross-project MCP) + + /// Record that the stream `streamId` finished. Stored in + /// `pendingStreamCompletions` for any `awaitStreamCompletion(...)` caller + /// (currently `ide__send_to_thread`) to pick up. Latest call wins, except + /// we don't overwrite a success with an error from the fallback path. + func recordStreamCompletion( + streamId: UUID, + sessionId: String, + assistantText: String, + error: String? + ) { + pendingStreamCompletions[streamId] = StreamCompletion( + sessionId: sessionId, + assistantText: assistantText, + error: error + ) + } + + /// Wait up to `timeout` seconds for the stream identified by `streamId` + /// to record a completion. Polls every 100ms — MainActor serialization + /// means the recorder fires between sleeps. Returns the completion if + /// one arrived in time, otherwise `nil`. + func awaitStreamCompletion(streamId: UUID, timeout: TimeInterval) async -> StreamCompletion? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let completion = pendingStreamCompletions.removeValue(forKey: streamId) { + return completion + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + return pendingStreamCompletions.removeValue(forKey: streamId) + } + + /// Discard a recorded completion. Called by long-running `wait_for_response=false` + /// MCP sends so the dictionary doesn't grow unbounded with abandoned results. + func discardStreamCompletion(streamId: UUID) { + pendingStreamCompletions.removeValue(forKey: streamId) + } + +} diff --git a/RxCode/App/AppState+MobileRemote.swift b/RxCode/App/AppState+MobileRemote.swift new file mode 100644 index 0000000..e1bf2fa --- /dev/null +++ b/RxCode/App/AppState+MobileRemote.swift @@ -0,0 +1,702 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Mobile: Skills / ACP / MCP remote management + + /// Error for malformed remote skill/ACP/MCP requests; its description is + /// surfaced verbatim to the mobile client. + enum MobileRemoteConfigError: LocalizedError { + case invalidRequest(String) + + var errorDescription: String? { + switch self { + case .invalidRequest(let detail): return detail + } + } + } + + /// The marketplace catalog flattened into wire DTOs with current install + /// state. `forceRefresh` bypasses the 5-minute marketplace cache. + func mobileSkillPlugins(forceRefresh: Bool = false) async -> [MobileSkillPlugin] { + let catalog = await marketplace.fetchCatalog(forceRefresh: forceRefresh) + let installed = await marketplace.installedPluginNames() + return catalog.map { plugin in + MobileSkillPlugin( + id: plugin.id, + name: plugin.name, + summary: plugin.description, + author: plugin.author, + category: plugin.category, + categoryLabel: plugin.categoryLabel, + marketplace: plugin.marketplace, + marketplaceLabel: plugin.marketplaceLabel, + homepage: plugin.homepage, + isInstalled: installed.contains(plugin.name) + ) + } + } + + func mobileSkillSources() async -> [MobileSkillSource] { + await marketplace.customSources().map { source in + MobileSkillSource(id: source.id, displayName: source.displayName) + } + } + + func handleMobileSkillCatalogRequest(_ request: SkillCatalogRequestPayload, fromHex: String) async { + let plugins = await mobileSkillPlugins(forceRefresh: request.forceRefresh) + let sources = await mobileSkillSources() + let result = SkillCatalogResultPayload( + clientRequestID: request.clientRequestID, + ok: true, + errorMessage: nil, + plugins: plugins, + sources: sources + ) + await MobileSyncService.shared.send(.skillCatalogResult(result), toHex: fromHex) + } + + func handleMobileSkillMutationRequest(_ request: SkillMutationRequestPayload, fromHex: String) async { + let catalog = await marketplace.fetchCatalog() + guard let plugin = catalog.first(where: { $0.id == request.pluginID }) else { + let result = SkillMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + pluginID: request.pluginID, + ok: false, + errorMessage: "Skill not found in the marketplace catalog.", + plugins: await mobileSkillPlugins(), + sources: await mobileSkillSources() + ) + await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) + return + } + + var ok = true + var errorMessage: String? + do { + switch request.operation { + case .install: + try await marketplace.installPlugin(plugin) + marketplaceInstalledNames.insert(plugin.name) + marketplacePluginStates[plugin.id] = .installed + case .uninstall: + try await marketplace.uninstallPlugin(plugin) + marketplaceInstalledNames.remove(plugin.name) + marketplacePluginStates[plugin.id] = .notInstalled + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] skill mutation failed plugin=\(plugin.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok { + let verb = request.operation == .install ? "installed" : "removed" + await NotificationService.shared.postRemoteConfigChanged( + title: "Skill \(verb) remotely", + body: plugin.name + ) + } + + let result = SkillMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + pluginID: request.pluginID, + ok: ok, + errorMessage: errorMessage, + plugins: await mobileSkillPlugins(), + sources: await mobileSkillSources() + ) + await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) + } + + func handleMobileSkillSourceMutationRequest(_ request: SkillSourceMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var sourceID = request.sourceID + var bannerTitle: String? + var bannerBody: String? + + do { + switch request.operation { + case .add: + guard let gitURL = request.gitURL, + !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw MobileRemoteConfigError.invalidRequest("Missing Git repository URL.") + } + let source = try await marketplace.addCustomGitSource(url: gitURL, ref: request.ref) + sourceID = source.id + marketplaceCustomSources = await marketplace.customSources() + bannerTitle = "Skill Git source added remotely" + bannerBody = source.displayName + case .remove: + let currentSources = await marketplace.customSources() + guard let sourceID = request.sourceID, + let source = currentSources.first(where: { $0.id == sourceID }) else { + throw MobileRemoteConfigError.invalidRequest("Skill Git source not found.") + } + try await marketplace.removeCustomGitSource(source) + marketplaceCustomSources = await marketplace.customSources() + bannerTitle = "Skill Git source removed remotely" + bannerBody = source.displayName + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] skill source mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + let plugins = await mobileSkillPlugins(forceRefresh: true) + let sources = await mobileSkillSources() + marketplaceCatalog = await marketplace.fetchCatalog() + marketplaceInstalledNames = await marketplace.installedPluginNames() + + if ok, let bannerTitle, let bannerBody { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + } + + let result = SkillSourceMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + sourceID: sourceID, + ok: ok, + errorMessage: errorMessage, + plugins: plugins, + sources: sources + ) + await MobileSyncService.shared.send(.skillSourceMutationResult(result), toHex: fromHex) + } + + func mobileACPRegistryAgents() -> [MobileACPRegistryAgent] { + let installedRegistryIDs = Set(acpClients.compactMap(\.registryId)) + return (acpRegistry?.agents ?? []).map { agent in + MobileACPRegistryAgent( + id: agent.id, + name: agent.name, + version: agent.version, + summary: agent.description, + authors: agent.authors ?? [], + license: agent.license, + website: agent.website, + iconURL: agent.icon, + isInstalled: installedRegistryIDs.contains(agent.id), + hasBinary: agent.distribution.binary?[ACPPlatform.current] != nil, + hasNpx: agent.distribution.npx != nil, + hasUvx: agent.distribution.uvx != nil + ) + } + } + + func mobileACPClients() -> [MobileACPClient] { + acpClients.map { spec in + MobileACPClient( + id: spec.id, + registryId: spec.registryId, + displayName: spec.displayName, + enabled: spec.enabled, + launchKind: spec.launch.displayKind, + modelCount: spec.models.count, + iconURL: spec.iconURL + ) + } + } + + func handleMobileACPRegistryRequest(_ request: ACPRegistryRequestPayload, fromHex: String) async { + await refreshACPRegistry(forceRefresh: request.forceRefresh) + let ok = acpRegistry != nil + let result = ACPRegistryResultPayload( + clientRequestID: request.clientRequestID, + ok: ok, + errorMessage: ok ? nil : "Could not load the ACP agent registry.", + registryAgents: mobileACPRegistryAgents(), + installedClients: mobileACPClients() + ) + await MobileSyncService.shared.send(.acpRegistryResult(result), toHex: fromHex) + } + + func handleMobileACPMutationRequest(_ request: ACPMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var bannerTitle: String? + var bannerBody: String? + do { + switch request.operation { + case .install: + guard let agentID = request.registryAgentID else { + throw MobileRemoteConfigError.invalidRequest("Missing registry agent id.") + } + if acpRegistry == nil { await refreshACPRegistry() } + guard let agent = acpRegistry?.agents.first(where: { $0.id == agentID }) else { + throw MobileRemoteConfigError.invalidRequest("Agent not found in the registry.") + } + let spec = try await installACPClient(from: agent) + addACPClient(spec) + bannerTitle = "ACP agent installed remotely" + bannerBody = agent.name + case .uninstall: + guard let clientID = request.clientID, + let client = acpClients.first(where: { $0.id == clientID }) + else { + throw MobileRemoteConfigError.invalidRequest("Installed client not found.") + } + removeACPClient(id: clientID) + bannerTitle = "ACP agent removed remotely" + bannerBody = client.displayName + case .setEnabled: + guard let clientID = request.clientID, + let enabled = request.enabled, + var client = acpClients.first(where: { $0.id == clientID }) + else { + throw MobileRemoteConfigError.invalidRequest("Installed client not found.") + } + client.enabled = enabled + updateACPClient(client) + bannerTitle = "ACP agent \(enabled ? "enabled" : "disabled") remotely" + bannerBody = client.displayName + } + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] acp mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok, let bannerTitle, let bannerBody { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) + } + + let result = ACPMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + ok: ok, + errorMessage: errorMessage, + registryAgents: mobileACPRegistryAgents(), + installedClients: mobileACPClients() + ) + await MobileSyncService.shared.send(.acpMutationResult(result), toHex: fromHex) + } + + func mobileMCPServer(_ record: MCPServerRecord) -> MobileMCPServer { + let env = record.env + .sorted { $0.key < $1.key } + .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } + let headers = record.headers + .sorted { $0.key < $1.key } + .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } + let endpoint: String + if record.transport == .stdio { + endpoint = ([record.command ?? ""] + record.args) + .filter { !$0.isEmpty } + .joined(separator: " ") + } else { + endpoint = record.url ?? "" + } + return MobileMCPServer( + name: record.name, + transport: record.transport.rawValue, + url: record.url, + command: record.command, + args: record.args, + env: env, + headers: headers, + isGloballyEnabled: record.isGloballyEnabled, + endpoint: endpoint + ) + } + + func mobileMCPServers() async throws -> [MobileMCPServer] { + try await mcp.globalRecords().map { mobileMCPServer($0) } + } + + func mcpServerSpec(from server: MobileMCPServer) -> MCPServerSpec { + MCPServerSpec( + name: server.name, + transport: MCPTransport(rawValue: server.transport) ?? .stdio, + url: server.url ?? "", + headers: server.headers.map { MCPKeyValue(key: $0.key, value: $0.value) }, + command: server.command ?? "", + args: server.args, + env: server.env.map { MCPKeyValue(key: $0.key, value: $0.value) } + ) + } + + func handleMobileMCPConfigRequest(_ request: MCPConfigRequestPayload, fromHex: String) async { + do { + let servers = try await mobileMCPServers() + let result = MCPConfigResultPayload( + clientRequestID: request.clientRequestID, + ok: true, + errorMessage: nil, + servers: servers + ) + await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) + } catch { + let result = MCPConfigResultPayload( + clientRequestID: request.clientRequestID, + ok: false, + errorMessage: error.localizedDescription, + servers: [] + ) + await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) + } + } + + func handleMobileMCPMutationRequest(_ request: MCPMutationRequestPayload, fromHex: String) async { + var ok = true + var errorMessage: String? + var bannerTitle: String? + do { + switch request.operation { + case .add: + guard let server = request.server else { + throw MobileRemoteConfigError.invalidRequest("Missing server definition.") + } + try await mcp.add(spec: mcpServerSpec(from: server), scope: .user, projectPath: nil) + bannerTitle = "MCP server saved remotely" + case .remove: + try await mcp.remove(name: request.serverName, scope: .user) + bannerTitle = "MCP server removed remotely" + case .setEnabled: + guard let enabled = request.enabled else { + throw MobileRemoteConfigError.invalidRequest("Missing enabled flag.") + } + try await mcp.setGlobalEnabled(name: request.serverName, enabled: enabled) + bannerTitle = "MCP server \(enabled ? "enabled" : "disabled") remotely" + } + await refreshMCPServers() + } catch { + ok = false + errorMessage = error.localizedDescription + logger.error("[MobileSync] mcp mutation failed server=\(request.serverName, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + + if ok, let bannerTitle { + await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: request.serverName) + } + + var servers: [MobileMCPServer] = [] + do { + servers = try await mobileMCPServers() + } catch { + logger.error("[MobileSync] failed reading mcp servers for reply: \(error.localizedDescription, privacy: .public)") + } + let result = MCPMutationResultPayload( + clientRequestID: request.clientRequestID, + operation: request.operation, + serverName: request.serverName, + ok: ok, + errorMessage: errorMessage, + servers: servers + ) + await MobileSyncService.shared.send(.mcpMutationResult(result), toHex: fromHex) + } + + func mobileFolderTreeRoot(for request: FolderTreeRequestPayload) throws -> RemoteFolderNode { + let depth = max(0, min(request.depth, 2)) + guard let path = request.path?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty + else { + return RemoteFolderNode( + name: Host.current().localizedName ?? "Mac", + path: "", + isSelectable: false, + children: mobileFolderPickerRoots(depth: 1, includeHidden: request.includeHidden) + ) + } + + return try mobileFolderNode( + for: URL(fileURLWithPath: path).standardizedFileURL, + depth: depth, + includeHidden: request.includeHidden + ) + } + + func mobileFolderPickerRoots(depth: Int, includeHidden: Bool) -> [RemoteFolderNode] { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL + var candidates = [ + home, + home.appendingPathComponent("Desktop", isDirectory: true), + home.appendingPathComponent("Documents", isDirectory: true), + home.appendingPathComponent("Downloads", isDirectory: true), + home.appendingPathComponent("Developer", isDirectory: true) + ] + + let projectParents = projects + .map { URL(fileURLWithPath: $0.path).deletingLastPathComponent().standardizedFileURL } + candidates.append(contentsOf: projectParents) + + var seen: Set = [] + return candidates.compactMap { url in + let path = url.path + guard seen.insert(path).inserted else { return nil } + return try? mobileFolderNode(for: url, depth: depth, includeHidden: includeHidden) + } + } + + func mobileFolderNode( + for url: URL, + depth: Int, + includeHidden: Bool + ) throws -> RemoteFolderNode { + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw MobileFolderPickerError.notFolder + } + + let name = url == fm.homeDirectoryForCurrentUser.standardizedFileURL + ? "Home" + : url.lastPathComponent + guard depth > 0 else { + return RemoteFolderNode(name: name, path: url.path, children: []) + } + + var options: FileManager.DirectoryEnumerationOptions = [] + if !includeHidden { options.insert(.skipsHiddenFiles) } + let contents = (try? fm.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey], + options: options + )) ?? [] + + let children = contents + .filter { child in + guard let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]), + values.isDirectory == true + else { return false } + if !includeHidden && (values.isHidden == true || child.lastPathComponent.hasPrefix(".")) { + return false + } + return !Self.mobileFolderIgnoredNames.contains(child.lastPathComponent) + } + .sorted { lhs, rhs in + lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending + } + .prefix(Self.mobileFolderMaxChildren) + .compactMap { child in + try? mobileFolderNode( + for: child.standardizedFileURL, + depth: depth - 1, + includeHidden: includeHidden + ) + } + + return RemoteFolderNode(name: name, path: url.path, children: Array(children)) + } + + func mobileFolderErrorMessage(_ error: Error) -> String { + if let folderError = error as? MobileFolderPickerError { + return folderError.localizedDescription + } + return error.localizedDescription + } + + static let mobileFolderMaxChildren = 250 + static let mobileFolderIgnoredNames: Set = [ + ".git", ".build", ".swiftpm", "DerivedData", + "node_modules", ".DS_Store", "Pods", "xcuserdata" + ] + + enum MobileFolderPickerError: LocalizedError { + case notFolder + + var errorDescription: String? { + switch self { + case .notFolder: + return "Folder does not exist on the desktop." + } + } + } + + func handleMobileCancelStream(_ cancel: CancelStreamPayload) async { + // The mobile may hold a session id the CLI has since advanced + // (pending-→real swap, or a compaction boundary). Follow the redirect + // chain so the cancel lands on the live, streaming thread — otherwise + // `sessionStates[sessionID]` is nil and the stop button is a no-op. + let sessionID = resolveCurrentSessionId(cancel.sessionID) + guard sessionStates[sessionID]?.isStreaming == true else { + logger.info("[MobileSync] cancel ignored — thread=\(sessionID, privacy: .public) (from \(cancel.sessionID, privacy: .public)) is not streaming") + return + } + let window = WindowState() + window.currentSessionId = sessionID + // Resolve the project so cancelStreaming can persist the partial + // messages accumulated up to the cancellation point. + if let summary = allSessionSummaries.first(where: { $0.id == sessionID }) { + window.selectedProject = projects.first(where: { $0.id == summary.projectId }) + } + await cancelStreaming(in: window) + // cancelStreaming intentionally skips finalizeStreamSession, so no + // status update is emitted — broadcast it so the mobile flips its + // stop button back to send. + broadcastMobileSessionStatus(sessionID: sessionID) + } + + func handleMobileUserMessage(_ message: UserMessagePayload, fromHex: String) async { + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + + let sessionID = resolveCurrentSessionId(message.sessionID) + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }), + let project = projects.first(where: { $0.id == summary.projectId }) + else { + logger.error("[MobileSync] user message for unknown thread=\(message.sessionID, privacy: .public)") + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + await hydrateMobileSessionIfNeeded(summary: summary, project: project) + updateMobilePlaceholderTitleIfNeeded(sessionID: sessionID, firstUserMessage: text) + + // If a turn is already streaming, the mobile message goes into the + // shared (threadStore-backed) queue so it flushes once the current turn + // ends — same behavior as the macOS InputBarView's enqueue path. + if sessionStates[sessionID]?.isStreaming == true { + let queued = QueuedMessage(text: text, attachments: []) + threadStore.appendQueued(sessionKey: sessionID, message: queued) + appendToWindowQueueMirrors(sessionID: sessionID, message: queued) + broadcastMobileSessionStatus(sessionID: sessionID) + return + } + + let window = WindowState() + window.selectedProject = project + window.currentSessionId = sessionID + if sessionID.hasPrefix("pending-mobile-") { + window.insertPendingPlaceholder(sessionID) + } + _ = await sendPrompt(text, displayText: text, in: window) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) + } + + func handleMobileRemoveQueuedMessage(_ payload: RemoveQueuedMessagePayload) { + threadStore.removeQueued(id: payload.queuedMessageID) + evictFromWindowQueueMirrors(sessionID: payload.sessionID, queuedID: payload.queuedMessageID) + broadcastMobileSessionStatus(sessionID: payload.sessionID) + } + + /// Flushes the next queued message from threadStore as a new user turn. + /// AppState is the single auto-flush authority — both macOS-side and + /// mobile-side queued items are flushed here, so the two flows never + /// race on duplicate sends. Triggered at the tail of `finalizeStreamSession`. + /// + /// Any macOS window currently viewing the session keeps a mirror copy of + /// the queue in `window.messageQueue`; clear the popped entry from those + /// mirrors so the UI doesn't show a stale queued row. + func flushNextQueuedMessageIfNeeded(sessionID: String) async { + let queue = threadStore.loadQueue(sessionKey: sessionID) + guard let next = queue.first else { return } + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }), + let project = projects.first(where: { $0.id == summary.projectId }) + else { return } + + threadStore.removeQueued(id: next.id) + evictFromWindowQueueMirrors(sessionID: sessionID, queuedID: next.id) + broadcastMobileSessionStatus(sessionID: sessionID) + + let window = WindowState() + window.selectedProject = project + window.currentSessionId = sessionID + _ = await sendPrompt( + next.text, + displayText: next.text, + attachments: next.attachments, + in: window + ) + } + + /// Mirror of `enqueueMessage` for queue items appended by AppState itself + /// (e.g. when a mobile-sent message arrives while the session is streaming). + /// Every desktop window currently viewing the session keeps an in-memory + /// copy in `messageQueue` and `draftQueues[sessionID]`; push the new entry + /// into those mirrors so the chat UI shows the queued row immediately, + /// matching the macOS enqueue path. + func appendToWindowQueueMirrors(sessionID: String, message: QueuedMessage) { + for window in registeredWindows() where window.currentSessionId == sessionID { + if !window.messageQueue.contains(where: { $0.id == message.id }) { + window.messageQueue.append(message) + } + var mirror = window.draftQueues[sessionID] ?? [] + if !mirror.contains(where: { $0.id == message.id }) { + mirror.append(message) + } + window.draftQueues[sessionID] = mirror + } + } + + /// macOS windows hold an in-memory mirror of the queue in `messageQueue` and + /// in `draftQueues[sessionID]`. When AppState pops an entry from threadStore + /// (auto-flush or remote remove), drop it from every registered window so + /// the UI doesn't show a phantom queued row. + func evictFromWindowQueueMirrors(sessionID: String, queuedID: UUID) { + for window in registeredWindows() where window.currentSessionId == sessionID { + window.messageQueue.removeAll { $0.id == queuedID } + if var mirror = window.draftQueues[sessionID] { + mirror.removeAll { $0.id == queuedID } + if mirror.isEmpty { + window.draftQueues.removeValue(forKey: sessionID) + } else { + window.draftQueues[sessionID] = mirror + } + } + } + } + + func createMobilePlaceholderSession(project: Project, requestID: UUID) -> String { + let sessionID = "pending-mobile-\(requestID.uuidString)" + if allSessionSummaries.contains(where: { $0.id == sessionID }) { + return sessionID + } + + let selection = defaultModelSelection(for: project) + let session = ChatSession( + id: sessionID, + projectId: project.id, + title: ChatSession.defaultTitle, + agentProvider: selection.provider, + model: selection.model, + origin: selection.provider.defaultSessionOrigin + ) + allSessionSummaries.insert(session.summary, at: 0) + threadStore.upsert(session.summary) + updateState(sessionID) { state in + state.agentProvider = selection.provider + state.model = selection.model + } + return sessionID + } + + func hydrateMobileSessionIfNeeded(summary: ChatSession.Summary, project: Project) async { + if sessionStates[summary.id] == nil, + let full = await persistence.loadFullSession(summary: summary, cwd: project.path) { + updateState(summary.id) { state in + state.messages = full.messages + } + } + + updateState(summary.id) { state in + if state.agentProvider == nil { state.agentProvider = summary.agentProvider } + if state.model == nil { state.model = summary.model } + if state.effort == nil { state.effort = summary.effort } + if state.permissionMode == nil { state.permissionMode = summary.permissionMode } + if state.worktreePath == nil { state.worktreePath = summary.worktreePath } + if state.worktreeBranch == nil { state.worktreeBranch = summary.worktreeBranch } + } + } + + func updateMobilePlaceholderTitleIfNeeded(sessionID: String, firstUserMessage: String) { + guard let index = allSessionSummaries.firstIndex(where: { $0.id == sessionID }) else { return } + let current = allSessionSummaries[index] + guard current.title == ChatSession.defaultTitle || current.title.isEmpty else { return } + allSessionSummaries[index].title = ChatSession.placeholderTitle(from: firstUserMessage) + allSessionSummaries[index].updatedAt = Date() + threadStore.upsert(allSessionSummaries[index]) + } + +} diff --git a/RxCode/App/AppState+MobileSnapshots.swift b/RxCode/App/AppState+MobileSnapshots.swift new file mode 100644 index 0000000..187dcd3 --- /dev/null +++ b/RxCode/App/AppState+MobileSnapshots.swift @@ -0,0 +1,797 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Mobile Snapshot Broadcasting + +extension AppState { + func scheduleMobileSnapshotBroadcast() { + mobileSnapshotBroadcastTask?.cancel() + mobileSnapshotBroadcastTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + await self?.broadcastMobileSnapshots() + } + } + + func broadcastMobileSnapshots() async { + for device in MobileSyncService.shared.pairedDevices { + await sendMobileSnapshot(toHex: device.pubkeyHex, activeSessionID: nil) + } + } + + func handleMobileSearchRequest(_ request: SearchRequestPayload, fromHex hex: String) async { + let trimmed = request.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + let empty = SearchResultsPayload( + clientRequestID: request.clientRequestID, + query: request.query, + projectIDs: [], + threadHits: [] + ) + await MobileSyncService.shared.send(.searchResults(empty), toHex: hex) + return + } + + let knownProjectIDs = Set(projects.map(\.id)) + let summaries = allSessionSummaries.filter { knownProjectIDs.contains($0.projectId) } + let summaryByID = Dictionary(uniqueKeysWithValues: summaries.map { ($0.id, $0) }) + + let semantic = await searchService.search(trimmed, limit: max(request.limit, 1)) + + var threadHitByID: [String: SearchHit] = [:] + for group in semantic { + for hit in group.hits { + guard let summary = summaryByID[hit.threadId] else { continue } + let title = ChatSession.stripAttachmentMarkers(from: summary.title) + threadHitByID[hit.threadId] = SearchHit( + sessionID: hit.threadId, + projectID: hit.projectId, + title: title.isEmpty ? ChatSession.defaultTitle : title, + snippet: hit.snippet, + updatedAt: summary.updatedAt, + score: hit.score + ) + } + } + + let lowered = trimmed.lowercased() + for summary in summaries where threadHitByID[summary.id] == nil { + let cleaned = ChatSession.stripAttachmentMarkers(from: summary.title) + guard cleaned.lowercased().contains(lowered) else { continue } + let title = cleaned.isEmpty ? ChatSession.defaultTitle : cleaned + threadHitByID[summary.id] = SearchHit( + sessionID: summary.id, + projectID: summary.projectId, + title: title, + snippet: title, + updatedAt: summary.updatedAt, + score: 0 + ) + } + + let threadHits = threadHitByID.values + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + return lhs.updatedAt > rhs.updatedAt + } + .prefix(max(request.limit, 1)) + + let projectIDs = projects + .filter { project in + project.name.lowercased().contains(lowered) + || project.path.lowercased().contains(lowered) + } + .map(\.id) + + let payload = SearchResultsPayload( + clientRequestID: request.clientRequestID, + query: request.query, + projectIDs: projectIDs, + threadHits: Array(threadHits) + ) + await MobileSyncService.shared.send(.searchResults(payload), toHex: hex) + } + + func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { + // Populate the OpenAI summarization model list so mobile's model + // picker has options. Fetched at most once — a prior failure (no API + // key, bad endpoint) is recorded in `openAISummarizationModelsError` + // and not retried on every snapshot. + if summarizationProvider == .openAI, + openAISummarizationModels.isEmpty, + openAISummarizationModelsError == nil, + !isLoadingOpenAISummarizationModels { + await refreshOpenAISummarizationModels() + } + let active = await mobileActiveSessionPayload(for: activeSessionID) + // Viewing a thread on any paired device counts as reading it: drop the + // "finished, unread" flag so the green indicator clears on desktop and + // mobile alike. Done before the snapshot is built so the payload below + // already reflects the cleared state. + if let activeID = active.id, sessionStates[activeID]?.hasUncheckedCompletion == true { + updateState(activeID) { $0.hasUncheckedCompletion = false } + } + let branches = await mobileProjectBranches() + // Keep mobile usage reasonably fresh. Non-forced — RateLimitService's + // 5-minute cache turns this into a no-op when usage was fetched + // recently, so frequent snapshots don't translate into API calls. A + // genuine refresh updates `latestRateLimitUsage`, which + // `observeMobileSnapshotInputs` tracks, re-broadcasting the snapshot. + Task { [weak self] in + await self?.refreshRateLimitUsage() + await self?.refreshCodexRateLimitUsage() + } + let hostMetrics = await SystemMetricsService.shared.sample() + let runProfiles = await mobileRunProfiles() + let runTasks = mobileRunTaskSnapshots() + let webProxy = await localWebProxy.proxyInfo() + if let webProxy { + logger.info("[WebBrowserSync] snapshot includes web proxy host=\(webProxy.host, privacy: .public) port=\(webProxy.port, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") + } else { + logger.warning("[WebBrowserSync] snapshot has no web proxy info to mobileKey=\(String(hex.prefix(12)), privacy: .public)") + } + let payload = SnapshotPayload( + projects: projects, + sessions: mobileSessionSummaries(), + branchBriefings: mobileBranchBriefings(), + threadSummaries: mobileThreadSummaries(), + settings: mobileSettingsSnapshot(), + activeSessionID: active.id, + activeSessionMessages: active.messages, + activeSessionHasMore: active.hasMore, + projectBranches: branches, + usage: MobileUsageSnapshot( + claudeCode: latestRateLimitUsage, + codex: latestCodexRateLimitUsage + ), + hostMetrics: hostMetrics, + runProfiles: runProfiles, + runTasks: runTasks, + webProxy: webProxy + ) + await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) + // The snapshot doesn't carry the question queue; send it alongside so a + // freshly connected device renders any outstanding question banner. + await MobileSyncService.shared.send( + .questionQueue(QuestionQueuePayload(questions: mobilePendingQuestionPayloads())), + toHex: hex + ) + logger.info( + "[MobileSync] sent snapshot projects=\(self.projects.count, privacy: .public) sessions=\(payload.sessions.count, privacy: .public) runProfileProjects=\(runProfiles.count, privacy: .public) runProfileTotal=\(runProfiles.reduce(0) { $0 + $1.profiles.count }, privacy: .public) runTasks=\(runTasks.count, privacy: .public) active=\(active.id ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)" + ) + } + + func mobileSessionSummaries() -> [RxCodeSync.SessionSummary] { + let knownProjectIds = Set(projects.map(\.id)) + return allSessionSummaries + .filter { knownProjectIds.contains($0.projectId) } + .sorted { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } + return lhs.updatedAt > rhs.updatedAt + } + .map { + mobileSessionSummary(from: $0) + } + } + + func mobileRunProfiles() async -> [MobileProjectRunProfiles] { + var result: [MobileProjectRunProfiles] = [] + for project in projects { + await ensureRunProfilesLoaded(for: project.id) + let profiles = runProfiles(for: project.id) + result.append(MobileProjectRunProfiles( + projectId: project.id, + profiles: profiles + )) + } + return result + } + + func mobileRunTaskSnapshots() -> [MobileRunTaskSnapshot] { + runService.tasks.map(mobileRunTaskSnapshot) + } + + func mobileRunTaskSnapshot(_ task: RunTask) -> MobileRunTaskSnapshot { + MobileRunTaskSnapshot( + taskId: task.id, + projectId: task.project.id, + profileId: task.profile.id, + profileName: task.profile.name, + status: mobileRunTaskStatus(task.status), + statusLabel: task.status.label, + exitCode: task.exitCode, + startedAt: task.startedAt, + resolvedCwd: task.resolvedCwd, + commandPreview: mobileRunTaskCommandPreview(task), + terminalOutputTail: String(task.terminalOutputTail.suffix(8_000)) + ) + } + + func mobileRunTaskStatus(_ status: RunTaskStatus) -> MobileRunTaskSnapshot.Status { + switch status { + case .running: return .running + case .succeeded: return .succeeded + case .failed: return .failed + case .signaled: return .signaled + case .stopped: return .stopped + } + } + + func mobileRunTaskCommandPreview(_ task: RunTask) -> String { + let lines = task.wrapperScript + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + if let mainIndex = lines.firstIndex(of: "# --- main ---") { + return lines.dropFirst(mainIndex + 1) + .prefix(8) + .joined(separator: "\n") + } + return lines.suffix(8).joined(separator: "\n") + } + + func broadcastMobileRunTasks() { + guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } + let currentSnapshots = runService.tasks.prefix(5).map(mobileRunTaskSnapshot) + let currentById = Dictionary(uniqueKeysWithValues: currentSnapshots.map { ($0.taskId, $0) }) + let removedIds = Set(lastBroadcastRunTaskSnapshots.keys).subtracting(currentById.keys) + if !removedIds.isEmpty { + scheduleMobileSnapshotBroadcast() + } + for snapshot in currentSnapshots where lastBroadcastRunTaskSnapshots[snapshot.taskId] != snapshot { + MobileSyncService.shared.broadcastRunTaskUpdate(snapshot) + } + lastBroadcastRunTaskSnapshots = currentById + } + + func mobileSessionSummary(for sessionID: String) -> RxCodeSync.SessionSummary? { + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { + return nil + } + return mobileSessionSummary(from: summary) + } + + func mobileSessionSummary(from summary: ChatSession.Summary) -> RxCodeSync.SessionSummary { + let todos = mobileTodoItems(forSessionId: summary.id) + let progress = mobileProgressSnapshot(forSessionId: summary.id) + let queued = threadStore.loadQueue(sessionKey: summary.id).map { + QueuedUserMessage(id: $0.id, text: $0.text) + } + return RxCodeSync.SessionSummary( + id: summary.id, + projectId: summary.projectId, + title: summary.title, + updatedAt: summary.updatedAt, + isPinned: summary.isPinned, + isArchived: summary.isArchived, + isStreaming: sessionStates[summary.id]?.isStreaming ?? false, + attention: mobileAttentionKind(forSessionId: summary.id), + progress: progress, + todos: todos, + queuedMessages: queued, + hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false + ) + } + + func mobileProgressSnapshot(forSessionId id: String) -> SessionProgressSnapshot? { + if let todos = mobileTodoItems(forSessionId: id) { + return SessionProgressSnapshot( + done: todos.filter { $0.status == .completed }.count, + total: todos.count, + inProgress: todos.contains { $0.status == .inProgress } + ) + } + + return nil + } + + func mobileTodoItems(forSessionId id: String) -> [TodoItem]? { + if let messages = sessionStates[id]?.messages, + let todos = TodoExtractor.latest(in: messages) + { + return todos + } + + guard let snapshot = threadStore.fetchTodoSnapshot(sessionId: id), snapshot.total > 0 else { + return nil + } + + return snapshot.items + } + + func mobileAttentionKind(forSessionId id: String) -> SessionAttentionKind? { + let requests = mobilePendingRequests.values.filter { $0.sessionId == id } + if requests.contains(where: { $0.toolName == "AskUserQuestion" }) { + return .question + } + if !requests.isEmpty { + return .permission + } + return nil + } + + /// Diff two message arrays for a session and emit `messageAppended` / + /// `messageUpdated` payloads for each change. Called from `updateState` + /// and `flushPendingUpdates` so every visible mutation reaches mobile + /// without per-call site instrumentation. + func broadcastMobileMessageDiff( + sessionKey: String, + prev: [ChatMessage], + next: [ChatMessage], + isStreaming: Bool + ) { + guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } + if prev.count == next.count, prev == next { return } + // Diff against the raw messages (so a CLI result overwrite still counts + // as a change), but bake the user-decision summary into the broadcast + // copy so the mobile plan banner clears once a plan is resolved. + let summaries = sessionStates[sessionKey]?.planDecisionSummaries ?? [:] + let prevById = Dictionary(uniqueKeysWithValues: prev.map { ($0.id, $0) }) + for message in next { + if let old = prevById[message.id] { + guard old != message else { continue } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionKey, + kind: .messageUpdated, + message: messageWithPlanDecisions(message, summaries: summaries), + isStreaming: isStreaming + ) + } else { + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionKey, + kind: .messageAppended, + message: messageWithPlanDecisions(message, summaries: summaries), + isStreaming: isStreaming + ) + } + } + } + + func broadcastMobileSessionStatus(sessionID: String, kind: SessionUpdatePayload.Kind = .statusChanged) { + guard let summary = mobileSessionSummary(for: sessionID) else { return } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionID, + kind: kind, + message: nil, + isStreaming: summary.isStreaming, + summary: summary + ) + } + + /// Wire representation of every `AskUserQuestion` call currently awaiting an + /// answer, used to mirror the desktop's question queue to mobile. + func mobilePendingQuestionPayloads() -> [PendingQuestionPayload] { + mobilePendingRequests.values + .filter { $0.toolName == "AskUserQuestion" } + .compactMap { request in + guard let sessionId = request.sessionId, + let data = try? JSONEncoder().encode(request.toolInput), + let json = String(data: data, encoding: .utf8) + else { return nil } + return PendingQuestionPayload( + toolUseID: request.id, + sessionID: sessionId, + toolInputJSON: json + ) + } + } + + /// Broadcast the current `AskUserQuestion` queue to paired mobile devices. + /// Called whenever the queue changes (a question is added or resolved) so + /// mobile mirrors it exactly — additions and retractions alike. + func broadcastMobileQuestionQueue() { + guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } + MobileSyncService.shared.broadcastQuestionQueue(mobilePendingQuestionPayloads()) + } + + func broadcastMobileSessionRedirect(from previousSessionID: String, to sessionID: String) { + guard previousSessionID != sessionID, + let summary = mobileSessionSummary(for: sessionID) + else { return } + MobileSyncService.shared.broadcastSessionUpdate( + sessionID: sessionID, + kind: .statusChanged, + message: nil, + isStreaming: summary.isStreaming, + summary: summary, + previousSessionID: previousSessionID + ) + scheduleMobileSnapshotBroadcast() + } + + func mobileBranchBriefings() -> [MobileBranchBriefing] { + let knownProjectIds = Set(projects.map(\.id)) + return threadStore.allBranchBriefingItems() + .filter { knownProjectIds.contains($0.projectId) } + .map { + MobileBranchBriefing( + projectId: $0.projectId, + branch: $0.branch, + briefing: $0.briefing, + updatedAt: $0.updatedAt + ) + } + } + + func mobileThreadSummaries() -> [MobileThreadSummary] { + let knownProjectIds = Set(projects.map(\.id)) + return threadStore.allThreadSummaryItems() + .filter { knownProjectIds.contains($0.projectId) } + .map { + MobileThreadSummary( + sessionId: $0.sessionId, + projectId: $0.projectId, + branch: $0.branch, + title: $0.title, + summary: $0.summary, + updatedAt: $0.updatedAt + ) + } + } + + func mobileSettingsSnapshot() -> MobileSettingsSnapshot { + let sections = availableAgentModelSections().map { + AgentModelSection( + id: $0.id, + title: $0.title, + provider: $0.provider, + iconURL: $0.iconURL, + models: $0.models + ) + } + let models = sections.flatMap(\.models) + return MobileSettingsSnapshot( + selectedAgentProvider: selectedAgentProvider, + selectedModel: selectedModel, + selectedACPClientId: selectedACPClientId, + selectedEffort: selectedEffort, + permissionMode: permissionMode, + summarizationProvider: summarizationProvider.rawValue, + summarizationProviderDisplayName: summarizationProvider.displayName, + openAISummarizationEndpoint: openAISummarizationEndpoint, + openAISummarizationModel: openAISummarizationModel, + notificationsEnabled: notificationsEnabled, + focusMode: focusMode, + autoArchiveEnabled: autoArchiveEnabled, + archiveRetentionDays: archiveRetentionDays, + autoPreviewSettings: autoPreviewSettings, + availableEfforts: ["auto"] + Self.availableEfforts, + availableModels: models, + modelSections: sections, + availableSummarizationProviders: SummarizationProvider.availableCases.map { + SummarizationProviderOption(id: $0.rawValue, displayName: $0.displayName) + }, + openAISummarizationModels: openAISummarizationModels + ) + } + + /// Resolve the current git branch and the local branch list for each known + /// project. Runs the per-project git calls concurrently so a large project + /// list doesn't serialize on disk I/O. + func mobileProjectBranches() async -> [ProjectBranchInfo] { + let inputs = projects.map { (id: $0.id, path: $0.path) } + return await withTaskGroup(of: ProjectBranchInfo?.self) { group in + for input in inputs { + group.addTask { + async let current = GitHelper.currentBranch(at: input.path) + async let list = GitHelper.listLocalBranches(at: input.path) + guard let branch = await current else { return nil } + let branches = await list + return ProjectBranchInfo( + projectId: input.id, + currentBranch: branch, + availableBranches: branches.isEmpty ? nil : branches + ) + } + } + var result: [ProjectBranchInfo] = [] + for await info in group { + if let info { result.append(info) } + } + return result + } + } + + func applyMobileSettingsUpdate(_ update: MobileSettingsUpdatePayload) { + if let provider = update.selectedAgentProvider { + selectedAgentProvider = provider + } + if let model = update.selectedModel { + selectedModel = model + } + if let clientId = update.selectedACPClientId { + selectedACPClientId = clientId + } + if let effort = update.selectedEffort, effort == "auto" || Self.availableEfforts.contains(effort) { + selectedEffort = effort + } + if let mode = update.permissionMode { + permissionMode = mode + } + if let rawProvider = update.summarizationProvider, + let provider = SummarizationProvider(rawValue: rawProvider), + SummarizationProvider.availableCases.contains(provider) { + summarizationProvider = provider + } + if let model = update.openAISummarizationModel { + openAISummarizationModel = model + } + if let enabled = update.notificationsEnabled { + notificationsEnabled = enabled + } + if let enabled = update.focusMode { + focusMode = enabled + } + if let enabled = update.autoArchiveEnabled { + autoArchiveEnabled = enabled + } + if let days = update.archiveRetentionDays { + archiveRetentionDays = max(1, min(365, days)) + } + if let previews = update.autoPreviewSettings { + autoPreviewSettings = previews + } + } + + /// Number of messages in one mobile history page. Mobile loads the most + /// recent page on subscribe and requests older pages as the user scrolls up. + static let mobileMessagePageSize = 30 + + /// Encoded byte ceiling for the message slice carried in one sync frame. + /// The relay caps a WebSocket frame at 10 MiB and the encrypted envelope + /// inflates the plaintext by roughly a third (base64), so an oversized + /// snapshot would have `task.send` throw and be silently dropped — leaving + /// mobile with only the live stream and no history. Holding the message + /// slice to 3 MiB leaves ample room for the rest of the snapshot (projects, + /// session summaries, briefings) and the envelope overhead; older messages + /// beyond the budget are paged in on demand via `load_more_messages`. + static let mobileMessagePageByteBudget = 3 * 1024 * 1024 + + /// Largest suffix of `messages[.. (page: [ChatMessage], startIndex: Int) { + let clampedEnd = min(max(end, 0), messages.count) + guard clampedEnd > 0, countLimit > 0 else { return ([], clampedEnd) } + let encoder = JSONEncoder() + var startIndex = clampedEnd + var byteCount = 0 + var index = clampedEnd - 1 + while index >= 0 { + let size = (try? encoder.encode(messages[index]))?.count ?? 0 + let takenSoFar = clampedEnd - startIndex + // Always accept the newest message; afterwards stop once either + // budget would be exceeded so the frame stays under the relay cap. + if takenSoFar > 0, + takenSoFar >= countLimit + || byteCount + size > Self.mobileMessagePageByteBudget { + break + } + byteCount += size + startIndex = index + index -= 1 + } + return (Array(messages[startIndex ..< clampedEnd]), startIndex) + } + + /// Single-entry cache of a disk-loaded session's full message list. Mobile + /// pages one thread at a time, so caching just the most recent one lets the + + /// Resolve the full, cleaned message list for a session — from live stream + /// state when available, otherwise from disk (cached). `nil` only when the + /// session genuinely can't be located. + func fullMobileMessages(for resolvedID: String) async -> [ChatMessage]? { + if let state = sessionStates[resolvedID] { + return messagesWithPlanDecisions( + cleanLoadedMessages(state.messages), + summaries: state.planDecisionSummaries + ) + } + // Non-active session: the in-memory sidecar is absent, so pull the + // persisted decisions from the thread store. + let summaries = threadStore.loadPlanDecisions(sessionId: resolvedID) + if let cache = mobileFullMessageCache, cache.sessionID == resolvedID { + return messagesWithPlanDecisions(cache.messages, summaries: summaries) + } + guard let summary = allSessionSummaries.first(where: { $0.id == resolvedID }), + let project = projects.first(where: { $0.id == summary.projectId }), + let full = await persistence.loadFullSession(summary: summary, cwd: project.path) + else { + return nil + } + let cleaned = cleanLoadedMessages(full.messages) + mobileFullMessageCache = (resolvedID, cleaned) + return messagesWithPlanDecisions(cleaned, summaries: summaries) + } + + /// Bake persisted plan decisions into `ExitPlanMode` tool results before + /// messages are sent to mobile. Once a plan resolves, the CLI overwrites the + /// tool result with its own follow-up text ("User has approved your plan…"), + /// which the desktop UI sidesteps with the `planDecisionSummaries` sidecar. + /// Mobile has no such sidecar — it reads `toolCall.result` directly — so the + /// user-decision summary must be written onto the wire result, otherwise the + /// mobile plan banner reappears after a decision. + func messagesWithPlanDecisions( + _ messages: [ChatMessage], + summaries: [String: String] + ) -> [ChatMessage] { + guard !summaries.isEmpty else { return messages } + return messages.map { messageWithPlanDecisions($0, summaries: summaries) } + } + + func messageWithPlanDecisions( + _ message: ChatMessage, + summaries: [String: String] + ) -> ChatMessage { + guard !summaries.isEmpty else { return message } + var result = message + for block in message.blocks { + guard let toolCall = block.toolCall, + PlanLogic.isExitPlanMode(toolCall), + let summary = summaries[toolCall.id] else { continue } + // Skip when the result is already the user-decision string. + if toolCall.result.map(PlanDecisionAction.isUserDecisionResult) != true { + result.setToolResult(id: toolCall.id, result: summary, isError: false) + } + } + return result + } + + /// Build the active-session payload for a snapshot: only the most recent + /// page of messages, plus whether older messages remain. Mobile pages the + /// rest in via `load_more_messages` so a snapshot never carries a whole + /// (potentially multi-MB) thread history in one frame. + func mobileActiveSessionPayload( + for requestedID: String? + ) async -> (id: String?, messages: [ChatMessage]?, hasMore: Bool) { + guard let requestedID else { return (nil, nil, false) } + let resolvedID = resolveCurrentSessionId(requestedID) + guard let all = await fullMobileMessages(for: resolvedID) else { + return (nil, nil, false) + } + let (page, startIndex) = mobileMessagePage( + from: all, + endingAt: all.count, + countLimit: Self.mobileMessagePageSize + ) + return (resolvedID, page, startIndex > 0) + } + + /// Reply to a mobile `load_more_messages` request with the page of messages + /// immediately older than `beforeMessageID`. + func handleMobileLoadMoreMessages( + _ request: LoadMoreMessagesRequestPayload, + fromHex: String + ) async { + let resolvedID = resolveCurrentSessionId(request.sessionID) + let all = await fullMobileMessages(for: resolvedID) ?? [] + guard let anchorIndex = all.firstIndex(where: { $0.id == request.beforeMessageID }) else { + // The anchor is gone (thread changed, or never loaded). Stop paging. + await MobileSyncService.shared.send( + .moreMessages(MoreMessagesPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + messages: [], + hasMore: false + )), + toHex: fromHex + ) + return + } + let limit = max(1, request.limit) + let (page, startIndex) = mobileMessagePage( + from: all, + endingAt: anchorIndex, + countLimit: limit + ) + await MobileSyncService.shared.send( + .moreMessages(MoreMessagesPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + messages: page, + hasMore: startIndex > 0 + )), + toHex: fromHex + ) + } + + /// Builds the change overview for the mobile "View Changes" sheet: every + /// file edited in the thread session plus the project's uncommitted git + /// changes. Replies with a `threadChangesResult`. + func handleMobileThreadChangesRequest( + _ request: ThreadChangesRequestPayload, + fromHex hex: String + ) async { + let resolvedID = resolveCurrentSessionId(request.sessionID) + + // This Turn: every file edited in the thread session (SwiftData history). + let turnEdits = threadStore.fetchFileEdits(sessionId: resolvedID).map { edit -> SyncFileEdit in + let summary = edit.toSummary() + return SyncFileEdit( + path: summary.path, + name: summary.name, + containsWrite: summary.containsWrite, + hunks: summary.hunks.map { + SyncEditHunk(oldString: $0.oldString, newString: $0.newString) + } + ) + } + + func reply(ok: Bool, error: String?, uncommitted: [SyncGitChange]) async { + await MobileSyncService.shared.send( + .threadChangesResult(ThreadChangesResultPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + ok: ok, + errorMessage: error, + turnEdits: turnEdits, + uncommitted: uncommitted + )), + toHex: hex + ) + } + + // Uncommitted: the session's project working tree. + let projectPath = allSessionSummaries + .first(where: { $0.id == resolvedID }) + .flatMap { summary in projects.first(where: { $0.id == summary.projectId })?.path } + + guard let projectPath, !projectPath.isEmpty else { + await reply(ok: false, error: "This thread has no associated project folder.", uncommitted: []) + return + } + + guard let gitChanges = await GitHelper.uncommittedChanges(at: projectPath) else { + await reply(ok: false, error: "This project is not a git repository.", uncommitted: []) + return + } + + let uncommitted = gitChanges.map { change -> SyncGitChange in + let kind: SyncGitChangeKind = switch change.kind { + case .staged: .staged + case .unstaged: .unstaged + case .untracked: .untracked + } + return SyncGitChange( + displayPath: change.displayPath, + statusChar: change.statusChar, + kind: kind, + unifiedDiff: change.unifiedDiff, + truncated: change.truncated + ) + } + await reply(ok: true, error: nil, uncommitted: uncommitted) + } + + /// User-triggered full reindex of every thread. Wipes cached embeddings, + /// then re-embeds every thread. Updates `reindexProgress` so the UI can + /// render a counter. + func reindexAllThreads() async { + guard reindexProgress == nil else { return } + reindexProgress = (0, 0) + let searchService = self.searchService + let threadStore = self.threadStore + let persistence = self.persistence + await searchService.reindexAll( + loadAll: { @MainActor in threadStore.loadAllSummaries() }, + loadFull: { @MainActor [weak self] summary -> ChatSession? in + let cwd = self?.projects.first(where: { $0.id == summary.projectId })?.path ?? "" + return await persistence.loadFullSession(summary: summary, cwd: cwd) + }, + progress: { [weak self] done, total in + Task { @MainActor in self?.reindexProgress = (done, total) } + } + ) + reindexProgress = nil + } + +} diff --git a/RxCode/App/AppState+MobileSync.swift b/RxCode/App/AppState+MobileSync.swift new file mode 100644 index 0000000..4c687e5 --- /dev/null +++ b/RxCode/App/AppState+MobileSync.swift @@ -0,0 +1,806 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Mobile Sync Bridge + +extension AppState { + func setupMobileSyncBridge() { + let center = NotificationCenter.default + let snapshotObserver = center.addObserver( + forName: .mobileSyncSnapshotRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String else { return } + let activeSessionID = notification.userInfo?["activeSessionID"] as? String + Task { @MainActor [weak self] in + await self?.sendMobileSnapshot(toHex: fromHex, activeSessionID: activeSessionID) + } + } + mobileSyncObservers.append(snapshotObserver) + + let settingsObserver = center.addObserver( + forName: .mobileSyncSettingsUpdateReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let update = notification.userInfo?["payload"] as? MobileSettingsUpdatePayload + else { return } + Task { @MainActor [weak self] in + self?.applyMobileSettingsUpdate(update) + await self?.sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + } + } + mobileSyncObservers.append(settingsObserver) + + let userMessageObserver = center.addObserver( + forName: .mobileSyncUserMessageReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let message = notification.userInfo?["payload"] as? UserMessagePayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileUserMessage(message, fromHex: fromHex) + } + } + mobileSyncObservers.append(userMessageObserver) + + let cancelStreamObserver = center.addObserver( + forName: .mobileSyncCancelStreamRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let cancel = notification.userInfo?["payload"] as? CancelStreamPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileCancelStream(cancel) + } + } + mobileSyncObservers.append(cancelStreamObserver) + + let removeQueuedObserver = center.addObserver( + forName: .mobileSyncRemoveQueuedRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let payload = notification.userInfo?["payload"] as? RemoveQueuedMessagePayload + else { return } + Task { @MainActor [weak self] in + self?.handleMobileRemoveQueuedMessage(payload) + } + } + mobileSyncObservers.append(removeQueuedObserver) + + let newSessionObserver = center.addObserver( + forName: .mobileSyncNewSessionRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? NewSessionRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileNewSessionRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(newSessionObserver) + + let threadActionObserver = center.addObserver( + forName: .mobileSyncThreadActionRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ThreadActionRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileThreadActionRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(threadActionObserver) + + let loadMoreObserver = center.addObserver( + forName: .mobileSyncLoadMoreMessagesRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? LoadMoreMessagesRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileLoadMoreMessages(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(loadMoreObserver) + + let searchObserver = center.addObserver( + forName: .mobileSyncSearchRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SearchRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSearchRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(searchObserver) + + let threadChangesObserver = center.addObserver( + forName: .mobileSyncThreadChangesRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ThreadChangesRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileThreadChangesRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(threadChangesObserver) + + let branchOpObserver = center.addObserver( + forName: .mobileSyncBranchOpRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? BranchOpRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileBranchOpRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(branchOpObserver) + + let folderTreeObserver = center.addObserver( + forName: .mobileSyncFolderTreeRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? FolderTreeRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileFolderTreeRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(folderTreeObserver) + + let createProjectObserver = center.addObserver( + forName: .mobileSyncCreateProjectRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? CreateProjectRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileCreateProjectRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(createProjectObserver) + + let runProfileMutationObserver = center.addObserver( + forName: .mobileSyncRunProfileMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunProfileMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunProfileMutation(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runProfileMutationObserver) + + let runProfileRunObserver = center.addObserver( + forName: .mobileSyncRunProfileRunRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunProfileRunRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunProfileRun(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runProfileRunObserver) + + let runProfileStopObserver = center.addObserver( + forName: .mobileSyncRunProfileStopRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? RunProfileStopRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileRunProfileStop(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(runProfileStopObserver) + + let questionAnswerObserver = center.addObserver( + forName: .mobileSyncQuestionAnswerReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let payload = notification.userInfo?["payload"] as? QuestionAnswerPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileQuestionAnswer(payload) + } + } + mobileSyncObservers.append(questionAnswerObserver) + + let planDecisionObserver = center.addObserver( + forName: .mobileSyncPlanDecisionReceived, + object: nil, + queue: nil + ) { [weak self] notification in + guard let payload = notification.userInfo?["payload"] as? PlanDecisionPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobilePlanDecision(payload) + } + } + mobileSyncObservers.append(planDecisionObserver) + + let skillCatalogObserver = center.addObserver( + forName: .mobileSyncSkillCatalogRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillCatalogRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillCatalogRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillCatalogObserver) + + let skillMutationObserver = center.addObserver( + forName: .mobileSyncSkillMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillMutationObserver) + + let skillSourceMutationObserver = center.addObserver( + forName: .mobileSyncSkillSourceMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? SkillSourceMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileSkillSourceMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(skillSourceMutationObserver) + + let acpRegistryObserver = center.addObserver( + forName: .mobileSyncACPRegistryRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ACPRegistryRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileACPRegistryRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(acpRegistryObserver) + + let acpMutationObserver = center.addObserver( + forName: .mobileSyncACPMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? ACPMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileACPMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(acpMutationObserver) + + let mcpConfigObserver = center.addObserver( + forName: .mobileSyncMCPConfigRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? MCPConfigRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileMCPConfigRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(mcpConfigObserver) + + let mcpMutationObserver = center.addObserver( + forName: .mobileSyncMCPMutationRequested, + object: nil, + queue: nil + ) { [weak self] notification in + guard let fromHex = notification.userInfo?["from"] as? String, + let request = notification.userInfo?["payload"] as? MCPMutationRequestPayload + else { return } + Task { @MainActor [weak self] in + await self?.handleMobileMCPMutationRequest(request, fromHex: fromHex) + } + } + mobileSyncObservers.append(mcpMutationObserver) + + observeMobileSnapshotInputs() + } + + func observeMobileSnapshotInputs() { + withObservationTracking { + _ = selectedAgentProvider + _ = selectedModel + _ = selectedACPClientId + _ = selectedEffort + _ = permissionMode + _ = notificationsEnabled + _ = focusMode + _ = autoArchiveEnabled + _ = archiveRetentionDays + _ = autoPreviewSettings + _ = branchBriefingRevision + _ = threadSummaryRevision + _ = projects.count + _ = allSessionSummaries.count + _ = latestRateLimitUsage + _ = latestCodexRateLimitUsage + _ = runProfilesByProject.count + } onChange: { + Task { @MainActor [weak self] in + self?.scheduleMobileSnapshotBroadcast() + self?.observeMobileSnapshotInputs() + } + } + } + + func handleMobileNewSessionRequest(_ request: NewSessionRequestPayload, fromHex: String) async { + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] new thread requested for unknown project=\(request.projectID.uuidString, privacy: .public)") + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + let initialText = request.initialText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !initialText.isEmpty else { + let sessionID = createMobilePlaceholderSession(project: project, requestID: request.clientRequestID) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: sessionID) + return + } + + let window = WindowState() + window.selectedProject = project + // Per-thread agent config captured in the mobile new-thread sheet. These + // session-scoped fields win over the project/global defaults in + // `effectiveModelSelection`, so the thread runs on exactly the chosen + // model — and starts in plan mode when requested. Carrying the config in + // the request also removes the `settings_update`/`newSessionRequest` + // ordering race. + if let provider = request.selectedAgentProvider { + window.sessionAgentProvider = provider + } + if let model = request.selectedModel, !model.isEmpty { + window.sessionModel = model + } + if let effort = request.selectedEffort, !effort.isEmpty, effort != "auto" { + window.sessionEffort = effort + } + if let mode = request.permissionMode { + window.sessionPermissionMode = mode + } + window.sessionPlanMode = request.planMode ?? false + if let pending = mobilePendingWorktrees.removeValue(forKey: project.id) { + window.pendingWorktreePath = pending.path + window.pendingWorktreeBranch = pending.branch + } + _ = await sendPrompt(initialText, displayText: initialText, in: window) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) + } + + /// Apply a mobile-initiated lifecycle action (rename / archive / unarchive / + /// delete) to an existing thread, then push a fresh snapshot back so the + /// requesting device reconciles immediately. The action mutators each + /// schedule their own broadcast for the remaining paired devices. + func handleMobileThreadActionRequest(_ request: ThreadActionRequestPayload, fromHex: String) async { + // The mobile may hold a session id the CLI has since advanced + // (pending-→real swap, or a compaction boundary). Follow the redirect + // chain so the action lands on the live thread. + let sessionID = resolveCurrentSessionId(request.sessionID) + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { + logger.error("[MobileSync] thread action=\(request.action.rawValue, privacy: .public) for unknown thread=\(request.sessionID, privacy: .public)") + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + return + } + + logger.info("[MobileSync] applying thread action=\(request.action.rawValue, privacy: .public) thread=\(sessionID, privacy: .public)") + + let session = summary.makeSession() + // Mobile actions aren't tied to a desktop window; a fresh WindowState + // keeps the data-layer mutators happy without disturbing open windows. + let window = WindowState() + + switch request.action { + case .rename: + let title = request.newTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !title.isEmpty else { break } + await renameSession(session, to: title) + case .archive: + await archiveSession(session, in: window) + case .unarchive: + await unarchiveSession(session, in: window) + case .delete: + await deleteSession(session, in: window) + } + + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + } + + func handleMobileBranchOpRequest(_ request: BranchOpRequestPayload, fromHex: String) async { + guard let project = projects.first(where: { $0.id == request.projectID }) else { + await replyBranchOpResult( + request: request, + ok: false, + errorMessage: "Project not found on desktop.", + toHex: fromHex + ) + return + } + + let trimmed = request.branch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + await replyBranchOpResult( + request: request, + ok: false, + errorMessage: "Branch name is required.", + toHex: fromHex + ) + return + } + + let window = WindowState() + window.selectedProject = project + + do { + switch request.operation { + case .switchExisting: + try await switchToExistingBranch(trimmed, in: window) + case .createNew: + try await attachWorktree(branch: trimmed, in: window) + if let path = window.pendingWorktreePath, + let branch = window.pendingWorktreeBranch + { + mobilePendingWorktrees[project.id] = MobilePendingWorktree(path: path, branch: branch) + } + } + } catch { + await replyBranchOpResult( + request: request, + ok: false, + errorMessage: branchOpErrorMessage(error), + toHex: fromHex + ) + return + } + + await replyBranchOpResult(request: request, ok: true, errorMessage: nil, toHex: fromHex) + scheduleMobileSnapshotBroadcast() + } + + func replyBranchOpResult( + request: BranchOpRequestPayload, + ok: Bool, + errorMessage: String?, + toHex hex: String + ) async { + let result = BranchOpResultPayload( + clientRequestID: request.clientRequestID, + projectID: request.projectID, + operation: request.operation, + branch: request.branch, + ok: ok, + errorMessage: errorMessage + ) + await MobileSyncService.shared.send(.branchOpResult(result), toHex: hex) + } + + func branchOpErrorMessage(_ error: Error) -> String { + if let werr = error as? GitWorktreeService.WorktreeError { + return werr.description + } + return error.localizedDescription + } + + func handleMobileFolderTreeRequest(_ request: FolderTreeRequestPayload, fromHex: String) async { + let result: FolderTreeResultPayload + do { + let root = try mobileFolderTreeRoot(for: request) + result = FolderTreeResultPayload( + clientRequestID: request.clientRequestID, + requestedPath: request.path, + ok: true, + root: root + ) + } catch { + result = FolderTreeResultPayload( + clientRequestID: request.clientRequestID, + requestedPath: request.path, + ok: false, + errorMessage: mobileFolderErrorMessage(error) + ) + } + await MobileSyncService.shared.send(.folderTreeResult(result), toHex: fromHex) + } + + func handleMobileCreateProjectRequest(_ request: CreateProjectRequestPayload, fromHex: String) async { + let trimmedPath = request.path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: false, + project: nil, + errorMessage: "Folder path is required.", + toHex: fromHex + ) + return + } + + let url = URL(fileURLWithPath: trimmedPath).standardizedFileURL + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: false, + project: nil, + errorMessage: "Folder does not exist on the desktop.", + toHex: fromHex + ) + return + } + + if projects.first(where: { $0.path == url.path }) == nil { + let isGitRepo = FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path) + let gitHubRepo = isGitRepo ? detectGitHubOwnerRepo(at: url.path) : nil + await addProject(name: url.lastPathComponent, path: url.path, gitHubRepo: gitHubRepo) + } + + guard let project = projects.first(where: { $0.path == url.path }) else { + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: false, + project: nil, + errorMessage: "Failed to add project on the desktop.", + toHex: fromHex + ) + return + } + + await replyCreateProjectResult( + requestID: request.clientRequestID, + ok: true, + project: project, + errorMessage: nil, + toHex: fromHex + ) + await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) + scheduleMobileSnapshotBroadcast() + } + + func replyCreateProjectResult( + requestID: UUID, + ok: Bool, + project: Project?, + errorMessage: String?, + toHex hex: String + ) async { + let result = CreateProjectResultPayload( + clientRequestID: requestID, + ok: ok, + project: project, + errorMessage: errorMessage + ) + await MobileSyncService.shared.send(.createProjectResult(result), toHex: hex) + } + + func handleMobileRunProfileMutation( + _ request: RunProfileMutationRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile mutation operation=\(request.operation.rawValue, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard projects.contains(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] run profile mutation rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + await ensureRunProfilesLoaded(for: request.projectID) + var profiles = runProfiles(for: request.projectID) + let now = Date() + + switch request.operation { + case .upsert: + guard var profile = request.profile else { + logger.error("[MobileSync] run profile upsert rejected missing payload project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Profile payload is missing.", + task: nil, + toHex: fromHex + ) + return + } + profile.projectId = request.projectID + profile.updatedAt = now + if let idx = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[idx] = profile + } else { + profile.createdAt = now + profiles.append(profile) + } + case .delete: + guard let profileID = request.profileID else { + logger.error("[MobileSync] run profile delete rejected missing profile id project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Profile id is missing.", + task: nil, + toHex: fromHex + ) + return + } + profiles.removeAll { $0.id == profileID } + } + + setRunProfiles(profiles, for: request.projectID) + logger.info("[MobileSync] run profile mutation applied project=\(request.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + task: nil, + toHex: fromHex + ) + } + + func handleMobileRunProfileRun( + _ request: RunProfileRunRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile run project=\(request.projectID.uuidString, privacy: .public) profile=\(request.profileID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + guard let project = projects.first(where: { $0.id == request.projectID }) else { + logger.error("[MobileSync] run profile run rejected unknown project=\(request.projectID.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Project not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + await ensureRunProfilesLoaded(for: request.projectID) + guard let profile = runProfiles(for: request.projectID).first(where: { $0.id == request.profileID }) else { + logger.error("[MobileSync] run profile run rejected missing profile=\(request.profileID.uuidString, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) knownProfiles=\(self.runProfiles(for: request.projectID).count, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: false, + errorMessage: "Run profile not found on desktop.", + task: nil, + toHex: fromHex + ) + return + } + + let task = runService.start(profile: profile, project: project) + logger.info("[MobileSync] run profile started task=\(task.id.uuidString, privacy: .public) profile=\(profile.name, privacy: .public) project=\(project.id.uuidString, privacy: .public)") + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID, + ok: true, + errorMessage: nil, + task: mobileRunTaskSnapshot(task), + toHex: fromHex + ) + } + + func handleMobileRunProfileStop( + _ request: RunProfileStopRequestPayload, + fromHex: String + ) async { + logger.info("[MobileSync] handling run profile stop task=\(request.taskID?.uuidString ?? "", privacy: .public) project=\(request.projectID?.uuidString ?? "", privacy: .public) profile=\(request.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") + let stoppedTask: RunTask? + if let taskID = request.taskID { + stoppedTask = runService.task(id: taskID) + runService.stop(taskId: taskID) + } else if let projectID = request.projectID, let profileID = request.profileID { + stoppedTask = runService.activeTasks.first { + $0.project.id == projectID && $0.profile.id == profileID + } + if let stoppedTask { + runService.stop(taskId: stoppedTask.id) + } + } else { + stoppedTask = nil + } + + await replyRunProfileResult( + requestID: request.clientRequestID, + projectID: request.projectID ?? stoppedTask?.project.id ?? UUID(), + ok: stoppedTask != nil, + errorMessage: stoppedTask == nil ? "No matching running task was found." : nil, + task: stoppedTask.map(mobileRunTaskSnapshot), + toHex: fromHex + ) + } + + func replyRunProfileResult( + requestID: UUID, + projectID: UUID, + ok: Bool, + errorMessage: String?, + task: MobileRunTaskSnapshot?, + toHex hex: String + ) async { + await ensureRunProfilesLoaded(for: projectID) + logger.info("[MobileSync] replying run profile result ok=\(ok, privacy: .public) project=\(projectID.uuidString, privacy: .public) profiles=\(self.runProfiles(for: projectID).count, privacy: .public) task=\(task?.taskId.uuidString ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public) error=\(errorMessage ?? "", privacy: .public)") + let result = RunProfileResultPayload( + clientRequestID: requestID, + projectID: projectID, + ok: ok, + errorMessage: errorMessage, + profiles: runProfiles(for: projectID), + task: task + ) + await MobileSyncService.shared.send(.runProfileResult(result), toHex: hex) + if ok { scheduleMobileSnapshotBroadcast() } + } + +} diff --git a/RxCode/App/AppState+Model.swift b/RxCode/App/AppState+Model.swift new file mode 100644 index 0000000..8bd216a --- /dev/null +++ b/RxCode/App/AppState+Model.swift @@ -0,0 +1,218 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Model + + static let availableModels = ["default", "best", "opus", "opus[1m]", "opusplan", "sonnet", "sonnet[1m]", "haiku"] + static let fallbackCodexModels = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"] + nonisolated static let defaultOpenAISummarizationEndpoint = "https://api.openai.com/v1" + static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization" + static let openAISummarizationKeychainAccount = "apiKey" + + static var availableClaudeModels: [AgentModel] { + availableModels.map { + AgentModel(provider: .claudeCode, id: $0, displayName: modelDisplayName($0), description: modelDescription($0)) + } + } + + static func availableCodexModels(_ discovered: [AgentModel]) -> [AgentModel] { + if !discovered.isEmpty { return discovered } + return fallbackCodexModels.map { + AgentModel(provider: .codex, id: $0, displayName: modelDisplayName($0, provider: .codex), description: modelDescription($0, provider: .codex)) + } + } + + 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]) + } + + 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 + } + + 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 { + modelDisplayName(model, provider: .claudeCode) + } + + static func modelDisplayName(_ model: String, provider: AgentProvider) -> String { + if provider == .codex { + return model + .replacingOccurrences(of: "-", with: " ") + .split(separator: " ") + .map { part in + part.uppercased().hasPrefix("GPT") ? part.uppercased() : part.capitalized + } + .joined(separator: " ") + } + switch model { + case "default": return "Default" + case "best": return "Best" + case "opus": return "Opus" + case "opus[1m]": return "Opus 1M" + case "opusplan": return "Opus Plan" + case "sonnet": return "Sonnet" + case "sonnet[1m]": return "Sonnet 1M" + case "haiku": return "Haiku" + default: return model.capitalized + } + } + + static func modelDescription(_ model: String) -> String { + modelDescription(model, provider: .claudeCode) + } + + static func modelDescription(_ model: String, provider: AgentProvider) -> String { + if provider == .codex { + switch model { + case "gpt-5.4": return "Balanced Codex model for everyday coding." + case "gpt-5.4-mini": return "Fast Codex model for lighter coding tasks." + case "gpt-5.3-codex": return "Codex-optimized coding model." + default: return "Codex model served by the Codex app-server." + } + } + 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 "sonnet[1m]": key = "model.desc.sonnet1m" + 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 .bypassPermissions: key = "perm.desc.bypassPermissions" + } + return NSLocalizedString(key, comment: "") + } + + static func effortDescription(_ effort: String) -> String { + let key: String + switch effort { + 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" + default: return "" + } + return NSLocalizedString(key, comment: "") + } +} diff --git a/RxCode/App/AppState+Project.swift b/RxCode/App/AppState+Project.swift new file mode 100644 index 0000000..363123b --- /dev/null +++ b/RxCode/App/AppState+Project.swift @@ -0,0 +1,324 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Project Management + + func addProject(name: String, path: String, gitHubRepo: String?) async { + guard !projects.contains(where: { $0.path == path }) else { return } + let project = Project(name: name, path: path, gitHubRepo: gitHubRepo) + projects.append(project) + do { + try await persistence.saveProjects(projects) + } catch { + logger.error("Failed to save projects: \(error.localizedDescription)") + } + } + + func selectProject(_ project: Project, in window: WindowState) { + guard window.selectedProject?.id != project.id else { return } + + saveDraft(in: window) + saveQueue(in: window) + + if isStreaming(in: window) { + detachCurrentStream(in: window) + } + + if let currentId = window.currentSessionId, + let currentProject = window.selectedProject, + let state = sessionStates[currentId], + !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.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, + worktreePath: summary?.worktreePath, + worktreeBranch: summary?.worktreeBranch, + isArchived: summary?.isArchived ?? false, + archivedAt: summary?.archivedAt + ) + Task { + do { try await self.persistence.saveSession(session) } + catch { self.logger.error("Failed to save current session before project switch: \(error.localizedDescription)") } + } + } + + // animation: nil — all mutations land in the same frame; sessionStates.filter fires + // one @Observable notification instead of N removeValue calls. + withAnimation(nil) { + window.showingBriefing = false + window.selectedProject = project + sessionStates = sessionStates.filter { $0.value.isStreaming } + resetToNewChat(in: window) + } + + activeProjectPath = project.path + Task { await refreshMCPServers() } + UserDefaults.standard.set(project.id.uuidString, forKey: "selectedProjectId") + } + + func addProjectFromFolder(_ url: URL, in window: WindowState) async { + let isGitRepo = FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path) + let gitHubRepo = isGitRepo ? detectGitHubOwnerRepo(at: url.path) : nil + await addAndSelectProject(name: url.lastPathComponent, path: url.path, gitHubRepo: gitHubRepo, in: window) + } + + func addAndSelectProject(name: String, path: String, gitHubRepo: String? = nil, in window: WindowState) async { + if let existing = projects.first(where: { $0.path == path }) { + selectProject(existing, in: window) + return + } + await addProject(name: name, path: path, gitHubRepo: gitHubRepo) + if let project = projects.last { + selectProject(project, in: window) + } + } + + // MARK: - Session Management + + func switchToSession(_ session: ChatSession, messages loadedMessages: [ChatMessage]? = nil, in window: WindowState) { + let existingState = sessionStates[session.id] + logger.info("[SwitchToSession] sid=\(session.id, privacy: .public) hasState=\(existingState != nil) existingMessages=\(existingState?.messages.count ?? -1) existingIsStreaming=\(existingState?.isStreaming ?? false) preloadedMessages=\(loadedMessages?.count ?? -1)") + saveDraft(in: window) + saveQueue(in: window) + + if isStreaming(in: window) { + detachCurrentStream(in: window) + } + + let outgoingId = window.currentSessionId + + if sessionStates[session.id] == nil { + var state = SessionStreamState() + state.agentProvider = session.agentProvider + state.model = session.model + state.effort = session.effort + state.permissionMode = session.permissionMode + if let msgs = loadedMessages { + state.messages = cleanLoadedMessages(msgs) + state.planDecisionSummaries = threadStore.loadPlanDecisions(sessionId: session.id) + sessionStates[session.id] = state + logger.info("[SwitchToSession] applied preloaded messages sid=\(session.id, privacy: .public) cleaned=\(state.messages.count)") + } else { + // Switch with an empty state first; actual messages are loaded in the background and injected later + state.isLoadingFromDisk = true + sessionStates[session.id] = state + if let project = window.selectedProject { + logger.info("[SwitchToSession] background load triggered sid=\(session.id, privacy: .public) cwd=\(project.path, privacy: .public)") + loadMessagesInBackground(projectId: project.id, sessionId: session.id, cwd: project.path) + } else { + logger.error("[SwitchToSession] no selectedProject — cannot load messages sid=\(session.id, privacy: .public)") + } + } + } else if sessionStates[session.id]?.messages.isEmpty == true, + sessionStates[session.id]?.isStreaming != true, + 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 } + if state.effort == nil { state.effort = session.effort } + if state.permissionMode == nil { state.permissionMode = session.permissionMode } + state.isLoadingFromDisk = true + sessionStates[session.id] = state + } + logger.info("[SwitchToSession] re-loading empty cached state sid=\(session.id, privacy: .public) cwd=\(project.path, privacy: .public)") + loadMessagesInBackground(projectId: project.id, sessionId: session.id, cwd: project.path) + } else { + logger.info("[SwitchToSession] reusing cached state sid=\(session.id, privacy: .public) messages=\(existingState?.messages.count ?? -1) isStreaming=\(existingState?.isStreaming ?? false)") + } + + if sessionStates[session.id]?.isStreaming == true { + flushPendingUpdates(for: session.id) + } + + updateState(session.id) { $0.hasUncheckedCompletion = false } + + window.showingBriefing = false + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil + window.currentSessionId = session.id + window.sessionAgentProvider = sessionStates[session.id]?.agentProvider ?? session.agentProvider + window.sessionModel = sessionStates[session.id]?.model ?? session.model + window.sessionEffort = sessionStates[session.id]?.effort ?? session.effort + window.sessionPermissionMode = sessionStates[session.id]?.permissionMode ?? session.permissionMode + window.sessionPlanMode = sessionStates[session.id]?.planMode ?? false + window.inputText = window.draftTexts[session.id] ?? "" + window.messageQueue = window.draftQueues[session.id] ?? [] + + releaseOutgoingSession(outgoingId, excluding: session.id, in: window) + + if sessionStates[session.id]?.isStreaming == true { + startFlushTimer(for: session.id) + } + } + + func releaseOutgoingSession(_ outgoingId: String?, excluding newId: String? = nil, in window: WindowState) { + guard let outgoingId, + outgoingId != newId, + !(sessionStates[outgoingId]?.isStreaming ?? false) else { return } + let outgoingMessages = sessionStates[outgoingId]?.messages ?? [] + Task { [weak self] in + guard let self else { return } + if !outgoingMessages.isEmpty, let project = window.selectedProject { + let summary = allSessionSummaries.first(where: { $0.id == outgoingId }) + let title = summary?.title ?? "Session" + let state = sessionStates[outgoingId] + 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, + 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)") } + } + if window.currentSessionId != outgoingId { + sessionStates.removeValue(forKey: outgoingId) + } + } + } + + func didSwitchToSession(_ session: ChatSession) async { + if let index = projects.firstIndex(where: { $0.id == session.projectId }) { + projects[index].lastSessionId = session.id + do { + try await persistence.saveProjects(projects) + } catch { + logger.error("Failed to save projects: \(error.localizedDescription)") + } + } + } + + func resumeSession(_ session: ChatSession, in window: WindowState) async { + switchToSession(session, in: window) + await didSwitchToSession(session) + } + + // MARK: - GitHub + + func loginToGitHub() async throws -> DeviceCodeResponse { + try await github.startDeviceFlow() + } + + func completeGitHubLogin(deviceCode: String, interval: Int) async throws { + _ = try await github.pollForToken(deviceCode: deviceCode, interval: interval) + + let user = try await github.fetchUser() + gitHubUser = user + isLoggedIn = true + onboardingCompleted = true + UserDefaults.standard.set(true, forKey: "onboardingCompleted") + + do { try await persistence.saveGitHubUser(user) } + catch { logger.error("Failed to cache GitHub user: \(error.localizedDescription)") } + + do { + let publicKey = try await github.setupSSH() + try await github.registerSSHKey(publicKey) + } catch { + logger.warning("SSH setup failed: \(error.localizedDescription)") + } + } + + func skipGitHubLogin() { + onboardingCompleted = true + UserDefaults.standard.set(true, forKey: "onboardingCompleted") + } + + + func fetchRepos() async { + isFetchingRepos = true + defer { isFetchingRepos = false } + do { repos = try await github.fetchRepos() } + catch { logger.error("Failed to fetch repos: \(error.localizedDescription)") } + } + + func cloneAndAddProject(_ repo: GitHubRepo, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(repo.name)" + let parentDir = "\(home)/RxCode" + let fm = FileManager.default + if !fm.fileExists(atPath: parentDir) { + try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true) + } + try await github.cloneRepo(repo, to: clonePath) + await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) + } + + func loadCustomRepos() async { + customRepos = await persistence.loadCustomRepos() + } + + func addCustomRepo(url: String, name: String, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + if fm.fileExists(atPath: clonePath) { + throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(name)' already exists in ~/RxCode"]) + } + try await github.cloneRepo(from: url, to: clonePath) + let repo = CustomRepo(name: name, cloneURL: url) + customRepos.append(repo) + try await persistence.saveCustomRepos(customRepos) + await addAndSelectProject(name: name, path: clonePath, gitHubRepo: nil, in: window) + } + + func cloneCustomRepo(_ repo: CustomRepo, in window: WindowState) async throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let clonePath = "\(home)/RxCode/\(repo.name)" + let fm = FileManager.default + if !fm.fileExists(atPath: "\(home)/RxCode") { + try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) + } + if fm.fileExists(atPath: clonePath) { + throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(repo.name)' already exists in ~/RxCode"]) + } + try await github.cloneRepo(from: repo.cloneURL, to: clonePath) + await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: nil, in: window) + } + + func removeCustomRepo(_ repo: CustomRepo) async { + customRepos.removeAll { $0.id == repo.id } + do { + try await persistence.saveCustomRepos(customRepos) + } catch { + logger.error("Failed to save custom repos: \(error.localizedDescription)") + } + } + +} diff --git a/RxCode/App/AppState+SessionLifecycle.swift b/RxCode/App/AppState+SessionLifecycle.swift new file mode 100644 index 0000000..3ef455d --- /dev/null +++ b/RxCode/App/AppState+SessionLifecycle.swift @@ -0,0 +1,625 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - View Convenience API + + func startNewChat(in window: WindowState) { + if isStreaming(in: window) { detachCurrentStream(in: window) } + saveDraft(in: window) + saveQueue(in: window) + releaseOutgoingSession(window.currentSessionId, in: window) + resetToNewChat(in: window) + } + + func resetToNewChat(in window: WindowState) { + window.showingBriefing = false + window.currentSessionId = nil + window.sessionAgentProvider = nil + window.sessionModel = nil + window.sessionEffort = nil + window.sessionPermissionMode = nil + window.sessionPlanMode = false + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil + sessionStates.removeValue(forKey: window.newSessionKey) + window.inputText = window.draftTexts[newDraftKey(for: window)] ?? "" + window.messageQueue = window.draftQueues[newDraftKey(for: window)] ?? [] + window.requestInputFocus = true + } + + func renameSession(_ session: ChatSession, to newTitle: String) async { + if let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) { + allSessionSummaries[si].title = newTitle + threadStore.upsert(allSessionSummaries[si]) + } + await updateSessionMetadata(session, persistTitle: true) { $0.title = newTitle } + broadcastMobileSessionStatus(sessionID: session.id) + } + + /// True if `currentTitle` looks like our auto-derived placeholder (matches what + /// `ChatSession.placeholderTitle(from:)` would produce). Used to decide whether + /// to overwrite with an LLM-generated title — never overwrite a user's manual rename. + func isAutoGeneratedTitle(_ currentTitle: String, firstUserMessage: String) -> Bool { + currentTitle == ChatSession.defaultTitle + || currentTitle == ChatSession.placeholderTitle(from: firstUserMessage) + || currentTitle == "New session" + || currentTitle.isEmpty + } + + /// Follow `sessionIdRedirect` chains to the current sid. The CLI may swap + /// `pending-` → real sid (and later advance the sid again on + /// `compact_boundary`) while a long-running task holds the old id. + func resolveCurrentSessionId(_ id: String) -> String { + var current = id + var seen: Set = [current] + while let next = sessionIdRedirect[current] { + if seen.contains(next) { break } + seen.insert(next) + current = next + } + return current + } + + /// Spawn a one-shot summarization call to generate a 3–6 word title for the given + /// session, then persist it via `renameSession` if the title is still the placeholder. + /// No-op if the session was already renamed manually or the LLM call fails. + func maybeGenerateLLMTitle(for sessionId: String) async { + let resolved = resolveCurrentSessionId(sessionId) + guard let summary = allSessionSummaries.first(where: { $0.id == resolved }) else { + logger.warning("Title generation skipped: no summary for \(resolved) (original \(sessionId))") + return + } + let messages = sessionStates[resolved]?.messages ?? [] + let firstUserRaw = messages.first(where: { $0.role == .user })?.content ?? "" + let firstUser = ChatSession.stripAttachmentMarkers(from: firstUserRaw) + guard !firstUser.isEmpty else { return } + guard isAutoGeneratedTitle(summary.title, firstUserMessage: firstUserRaw) else { return } + guard let title = await generateSessionTitle(firstUserMessage: firstUser, summary: summary) else { return } + // Re-resolve after the LLM call — the id may have been swapped while we waited. + let currentId = resolveCurrentSessionId(sessionId) + guard let stillPlaceholder = allSessionSummaries.first(where: { $0.id == currentId }), + isAutoGeneratedTitle(stillPlaceholder.title, firstUserMessage: firstUser) else { return } + guard let project = projects.first(where: { $0.id == stillPlaceholder.projectId }) else { return } + let session = ChatSession( + id: currentId, + projectId: project.id, + title: title, + messages: sessionStates[currentId]?.messages ?? messages, + 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) + } + + func generateSessionTitle(firstUserMessage: String, summary: ChatSession.Summary) async -> String? { + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateSessionTitle(firstUserMessage: firstUserMessage, provider: provider, model: model) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateSessionTitle( + firstUserMessage: firstUserMessage, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .appleFoundationModel: + return await foundationModelSummarization.generateSessionTitle(firstUserMessage: firstUserMessage) + } + } + + func generateResponseNotificationSummary(responseText: String, summary: ChatSession.Summary) async -> String? { + let trimmedResponse = responseText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedResponse.isEmpty else { return nil } + + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateResponseNotificationSummary(responseText: trimmedResponse, provider: provider, model: model) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateResponseNotificationSummary( + responseText: trimmedResponse, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .appleFoundationModel: + return await foundationModelSummarization.generateResponseNotificationSummary(responseText: trimmedResponse) + } + } + + func scheduleThreadSummaryUpdate( + sessionId: String, + projectId: UUID, + cwd: String, + messages: [ChatMessage] + ) { + let userMessage = lastUserMessageText(in: messages) + let finalResponse = lastAssistantResponseText(in: messages) + guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } + + let summary = allSessionSummaries.first(where: { $0.id == sessionId }) + ?? summaryFor(sessionId: sessionId, projectId: projectId) + + Task { [weak self] in + guard let self else { return } + await self.updateStoredThreadSummary( + sessionId: sessionId, + projectId: projectId, + cwd: cwd, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) + } + } + + func updateStoredThreadSummary( + sessionId: String, + projectId: UUID, + cwd: String, + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async { + let previousSummary = threadStore.threadSummaryItem(sessionId: sessionId)?.summary + guard let threadSummary = await generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + summary: summary + ) else { return } + + let branchPath = summary.worktreePath ?? cwd + let currentBranch = await GitHelper.currentBranch(at: branchPath) + let branch = summary.worktreeBranch ?? currentBranch ?? "unknown" + let title = summary.title.isEmpty ? ChatSession.defaultTitle : summary.title + + threadStore.upsertThreadSummary( + sessionId: sessionId, + projectId: projectId, + branch: branch, + title: title, + summary: threadSummary + ) + threadSummaryRevision &+= 1 + + let allThreadSummaries = threadStore + .threadSummaryItems(projectId: projectId, branch: branch) + .map { (title: $0.title, summary: $0.summary) } + guard let briefing = await generateBranchBriefing( + threadSummaries: allThreadSummaries, + summary: summary + ) else { return } + + threadStore.upsertBranchBriefing(projectId: projectId, branch: branch, briefing: briefing) + branchBriefingRevision &+= 1 + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async -> String? { + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + provider: provider, + model: model + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .appleFoundationModel: + return await foundationModelSummarization.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse + ) + } + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + summary: ChatSession.Summary + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateBranchBriefing( + threadSummaries: threadSummaries, + provider: provider, + model: model + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateBranchBriefing( + threadSummaries: threadSummaries, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .appleFoundationModel: + return await foundationModelSummarization.generateBranchBriefing(threadSummaries: threadSummaries) + } + } + + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + summary: ChatSession.Summary + ) async -> String? { + switch summarizationProvider { + case .selectedClient: + let provider = summary.agentProvider + let model = summary.model ?? selectedSummarizationModel(for: provider) + return await generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + provider: provider, + model: model + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { return nil } + return await openAISummarization.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .appleFoundationModel: + return await foundationModelSummarization.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + } + } + + /// Generates a commit message for the staged changes in the given project. + /// Routes through the configured `summarizationProvider`. Returns nil on + /// failure or when no provider is configured. Public so the Changes view + /// can invoke it from the UI thread. + /// + /// `diff` and `stat` should come from `GitHelper.stagedDiff` / + /// `GitHelper.stagedStat`. We compact them per-provider so very large + /// patches don't blow past the model's context window — the small + /// on-device Foundation Model gets a much tighter budget than the + /// cloud-hosted providers. + func generateCommitMessage( + diff: String, + stat: String, + fileSummary: String + ) async -> String? { + let raw: String? + switch summarizationProvider { + case .appleFoundationModel: + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) + raw = await foundationModelSummarization.generateCommitMessage( + diff: context, + fileSummary: fileSummary + ) + case .openAI: + if openAISummarizationModel.isEmpty { + if FoundationModelSummarizationService.isAvailable { + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) + raw = await foundationModelSummarization.generateCommitMessage( + diff: context, + fileSummary: fileSummary + ) + } else { + raw = nil + } + } else { + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 16_000) + raw = await openAISummarization.generateCommitMessage( + diff: context, + fileSummary: fileSummary, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + } + case .selectedClient: + if FoundationModelSummarizationService.isAvailable { + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) + raw = await foundationModelSummarization.generateCommitMessage( + diff: context, + fileSummary: fileSummary + ) + } else { + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 16_000) + raw = await claude.generateCommitMessage(diff: context, fileSummary: fileSummary) + } + } + return Self.sanitizeCommitMessage(raw) + } + + /// Builds a compact diff context that fits within `budget` characters. + /// When the raw diff fits, returns it as-is (prefixed with the stat). + /// When it doesn't, splits by `diff --git` boundaries and gives each file + /// a fair share of the remaining budget — keeping the header plus the + /// leading lines of the patch so the model still sees what changed in + /// each file, even if deeper context is dropped. + static func buildCommitContext(diff: String, stat: String, budget: Int) -> String { + let statBlock: String = { + let trimmed = stat.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "" : "Diff stat:\n\(trimmed)\n\n" + }() + + let trimmedDiff = diff.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedDiff.isEmpty { + return statBlock + "Full diff:\n(none)" + } + + // Fast path — total fits within budget. + if statBlock.count + trimmedDiff.count <= budget { + return statBlock + "Full diff:\n" + trimmedDiff + } + + // Split by file boundaries. The first chunk before any "diff --git" is + // ignored (git always starts file blocks with that marker). + let marker = "\ndiff --git " + var fileBlocks: [String] = [] + var remaining = "\n" + trimmedDiff + while let range = remaining.range(of: marker) { + let nextStart = remaining.index(range.lowerBound, offsetBy: 1) // drop leading "\n" + if let next = remaining.range(of: marker, range: range.upperBound..= budgetForDiffs { break } + } + + return statBlock + "Truncated diff (file-by-file):\n" + assembled + } + + /// Strips markdown wrappers and ensures the message starts with a + /// Conventional Commits `type:` line. Defends against models that add + /// headings, code fences, or quoted text despite explicit prompts. + static func sanitizeCommitMessage(_ raw: String?) -> String? { + guard let raw else { return nil } + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { return nil } + + // Strip fenced code blocks: ```...``` (any language tag). + if text.hasPrefix("```") { + var lines = text.components(separatedBy: "\n") + if !lines.isEmpty { lines.removeFirst() } + if let last = lines.last?.trimmingCharacters(in: .whitespaces), last == "```" { + lines.removeLast() + } + text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Strip surrounding quotes/backticks if the whole message is wrapped. + if let first = text.first, let last = text.last, + "\"'`".contains(first), first == last, text.count > 1 { + text = String(text.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Drop leading lines that look like markdown headings or empty lines + // until we reach a Conventional Commits subject (or any plain text). + let conventionalPrefixes = [ + "feat", "fix", "docs", "style", "refactor", "perf", + "test", "build", "ci", "chore", "revert" + ] + var lines = text.components(separatedBy: "\n") + while let first = lines.first { + let trimmed = first.trimmingCharacters(in: .whitespaces) + let isHeading = trimmed.hasPrefix("#") + let isEmpty = trimmed.isEmpty + let startsWithType = conventionalPrefixes.contains { type in + trimmed.lowercased().hasPrefix(type + ":") || + trimmed.lowercased().hasPrefix(type + "(") + } + if startsWithType { break } + if isHeading || isEmpty { + lines.removeFirst() + continue + } + break + } + text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + func generateSessionTitle(firstUserMessage: String, provider: AgentProvider, model: String?) async -> String? { + switch provider { + case .claudeCode: + 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 + } + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + provider: AgentProvider, + model: String? + ) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + model: model ?? "haiku" + ) + case .codex: + return await codex.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse, + model: model + ) + case .acp: + return nil + } + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + provider: AgentProvider, + model: String? + ) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateBranchBriefing( + threadSummaries: threadSummaries, + model: model ?? "haiku" + ) + case .codex: + return await codex.generateBranchBriefing( + threadSummaries: threadSummaries, + model: model + ) + case .acp: + return nil + } + } + + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + provider: AgentProvider, + model: String? + ) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + model: model ?? "haiku" + ) + case .codex: + return await codex.generateMemoryOperations( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse, + model: model + ) + case .acp: + return nil + } + } + + func generateResponseNotificationSummary(responseText: String, provider: AgentProvider, model: String?) async -> String? { + switch provider { + case .claudeCode: + return await claude.generateResponseNotificationSummary(responseText: responseText, model: model ?? "haiku") + case .codex: + return await codex.generateResponseNotificationSummary(responseText: responseText, model: model) + case .acp: + // No standardized one-shot generation in ACP; keep the local preview fallback. + return nil + } + } + + func lastAssistantResponseText(in messages: [ChatMessage]) -> String { + guard let message = messages.last(where: { $0.role == .assistant && !$0.isError }) else { + return "" + } + return message.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func lastUserMessageText(in messages: [ChatMessage]) -> String { + guard let message = messages.last(where: { $0.role == .user && !$0.isError }) else { + return "" + } + return ChatSession.stripAttachmentMarkers(from: message.content) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + func responseNotificationFallback(from responseText: String) -> String { + let text = responseText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return "" } + let sentence = text.components(separatedBy: CharacterSet(charactersIn: ".!?\n")).first ?? text + return sentence.trimmingCharacters(in: .whitespaces) + } + + func selectedSummarizationModel(for provider: AgentProvider) -> String? { + if selectedAgentProvider == provider { + return selectedModel + } + return availableAgentModelSections() + .first(where: { $0.provider == provider })? + .models + .first? + .id + } + + func togglePinSession(_ session: ChatSession) async { + guard let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) else { return } + allSessionSummaries[si].isPinned.toggle() + let newIsPinned = allSessionSummaries[si].isPinned + threadStore.upsert(allSessionSummaries[si]) + await updateSessionMetadata(session) { $0.isPinned = newIsPinned } + scheduleMobileSnapshotBroadcast() + } + +} diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift new file mode 100644 index 0000000..dca6b1d --- /dev/null +++ b/RxCode/App/AppState+Stream.swift @@ -0,0 +1,646 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Text Delta Throttle (50ms) + + func startFlushTimer(for sessionKey: String) { + stopFlushTimer(for: sessionKey) + let capturedKey = sessionKey + let task = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 50_000_000) + guard !Task.isCancelled else { break } + self?.flushPendingUpdates(for: capturedKey) + } + } + sessionStates[sessionKey, default: SessionStreamState()].flushTask = task + } + + func stopFlushTimer(for sessionKey: String) { + sessionStates[sessionKey]?.flushTask?.cancel() + sessionStates[sessionKey]?.flushTask = nil + } + + func flushPendingUpdates(for key: String) { + guard var state = sessionStates[key] else { return } + + let hasText = !state.textDeltaBuffer.isEmpty + let hasToolResults = !state.pendingToolResults.isEmpty + + guard hasText || hasToolResults else { return } + + let prevMessages = state.messages + + func lastAssistantIdx() -> Int? { + state.messages.indices.reversed().first { state.messages[$0].role == .assistant } + } + func lastStreamingAssistantIdx() -> Int? { + state.messages.indices.reversed().first { state.messages[$0].role == .assistant && state.messages[$0].isStreaming } + } + + // 1. Tool results — apply to the current streaming assistant message + if hasToolResults { + let results = state.pendingToolResults + state.pendingToolResults.removeAll(keepingCapacity: true) + if let idx = lastAssistantIdx() { + for (toolUseId, content, isError) in results { + let editPersistInfos: [(path: String, hunks: [PreviewFile.EditHunk], isWrite: Bool)] = { + guard !isError, + let blockIdx = state.messages[idx].toolCallIndex(id: toolUseId), + let call = state.messages[idx].blocks[blockIdx].toolCall, + ["edit", "multiedit", "multi_edit", "write"].contains(call.name.lowercased()) + else { return [] } + let claudeHunks = call.fileEditHunks + if !claudeHunks.isEmpty, let path = call.editedFilePath { + return [(path, claudeHunks, call.name.lowercased() == "write")] + } + // Codex `fileChange` shape: one tool call may touch multiple files. + let codexDiffs = call.fileChangeDiffs + guard !codexDiffs.isEmpty else { return [] } + return codexDiffs.map { ($0.path, [$0.hunk], false) } + }() + // Preserve the user-decision summary on ExitPlanMode. After the user + // accepts/rejects the plan, the CLI emits its own follow-up tool_result + // ("User has approved your plan…") which would overwrite "Accepted with …" + // and flip the plan card back to "pending" — re-showing the accept buttons + // on a card that was just decided. + let skipResultOverwrite: Bool = { + guard let blockIdx = state.messages[idx].toolCallIndex(id: toolUseId), + let call = state.messages[idx].blocks[blockIdx].toolCall, + Self.isExitPlanModeCall(call), + let existing = call.result else { return false } + return Self.planDecisionResultPrefixes.contains { existing.hasPrefix($0) } + }() + if !skipResultOverwrite { + state.messages[idx].setToolResult(id: toolUseId, result: content, isError: isError) + } + if !editPersistInfos.isEmpty { + for info in editPersistInfos { + threadStore.appendFileEdit( + sessionId: key, + path: info.path, + hunks: info.hunks, + containsWrite: info.isWrite + ) + } + threadFileEditsRevision &+= 1 + } + } + } + } + + // 2. Text delta flush + if hasText { + let buffered = state.textDeltaBuffer + state.textDeltaBuffer = "" + if let idx = lastStreamingAssistantIdx() { + if state.needsNewMessage { + // New Claude turn after receiving tool result — start a new ChatMessage + state.messages[idx].isStreaming = false + state.messages[idx].finalizeToolCalls() + Self.stripNoOpText(at: idx, in: &state.messages) + state.needsNewMessage = false + state.messages.append(ChatMessage(role: .assistant, content: buffered, isStreaming: true)) + } else { + state.messages[idx].appendText(buffered) + } + } else { + state.messages.append(ChatMessage(role: .assistant, content: buffered, isStreaming: true)) + } + } + + sessionStates[key] = state + broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) + } + + // MARK: - Stream Event Handler + + func handlePartialEvent(_ raw: String, for sessionKey: String) { + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + + let event: [String: Any] + if let type = json["type"] as? String, type == "stream_event", + let nested = json["event"] as? [String: Any] + { + event = nested + } else { + event = json + } + + guard let eventType = event["type"] as? String else { return } + + switch eventType { + case "content_block_start": + guard let contentBlock = event["content_block"] as? [String: Any], + let blockType = contentBlock["type"] as? String else { return } + + if blockType == "tool_use" { + guard let id = contentBlock["id"] as? String, + let name = contentBlock["name"] as? String else { return } + let toolCall = ToolCall(id: id, name: name, input: [:]) + // Flush the text buffer first so text blocks are committed before tools + flushPendingUpdates(for: sessionKey) + updateState(sessionKey) { state in + state.isThinking = false + // needsNewMessage: new Claude turn after tool result — create a new ChatMessage + if state.needsNewMessage { + if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }) { + state.messages[idx].isStreaming = false + state.messages[idx].finalizeToolCalls() + Self.stripNoOpText(at: idx, in: &state.messages) + } + state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) + state.needsNewMessage = false + } else if state.messages.last?.role != .assistant || !(state.messages.last?.isStreaming ?? false) { + state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) + } + if let lastIndex = state.messages.indices.last, + state.messages[lastIndex].role == .assistant + { + state.messages[lastIndex].appendToolCall(toolCall) + } + // Ready to receive input_json_delta + state.activeToolId = id + state.activeToolInputBuffer = "" + } + } else if blockType == "text" { + // New text block started — if needsNewMessage, prepare a new ChatMessage + updateState(sessionKey) { state in + if state.needsNewMessage { + // Keep the flag so a new message is created on the next text_delta flush + // (needsNewMessage is handled inside flush) + } + state.isThinking = false + state.activeToolId = nil + state.activeToolInputBuffer = "" + } + } else if blockType == "thinking" { + updateState(sessionKey) { $0.isThinking = true } + } + + case "content_block_delta": + guard let delta = event["delta"] as? [String: Any], + let deltaType = delta["type"] as? String else { return } + + if deltaType == "text_delta", let text = delta["text"] as? String { + updateState(sessionKey) { state in + state.isThinking = false + state.textDeltaBuffer += text + } + } else if deltaType == "input_json_delta", let partial = delta["partial_json"] as? String { + updateState(sessionKey) { state in + state.activeToolInputBuffer += partial + } + } else if deltaType == "thinking_delta" { + updateState(sessionKey) { $0.isThinking = true } + } + + case "content_block_stop": + // Finalize tool_use input — parse the accumulated JSON and apply to the tool call + updateState(sessionKey) { state in + guard let toolId = state.activeToolId, !state.activeToolInputBuffer.isEmpty else { + state.activeToolId = nil + return + } + let buffer = state.activeToolInputBuffer + state.activeToolId = nil + state.activeToolInputBuffer = "" + + guard let inputData = buffer.data(using: .utf8), + 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) + { + state.messages[msgIdx].blocks[blockIdx].toolCall?.input = parsed + if let toolName = state.messages[msgIdx].blocks[blockIdx].toolCall?.name, + 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 ?? "-" + logger.info( + "[TodoWrite] session=\(sessionKey, privacy: .public) total=\(todos.count) done=\(done) active=\(active, privacy: .public)" + ) + threadStore.upsertTodoSnapshot(sessionId: sessionKey, items: todos) + } + } + } + + default: + break + } + } + + // MARK: - Cancel + + func detachCurrentStream(in window: WindowState) { + let key = window.currentSessionId ?? window.newSessionKey + flushPendingUpdates(for: key) + stopFlushTimer(for: key) + } + + func cancelStreaming(in window: WindowState) async { + let key = window.currentSessionId ?? window.newSessionKey + let streamToCancel = sessionStates[key]?.activeStreamId + sessionStates[key]?.streamTask?.cancel() + + // Finalize the in-progress turn *before* the await below, in a single + // state mutation. The session `isStreaming` flag and the streaming + // message's own `isStreaming` flag must flip to false together: if they + // disagree when the message list rebuilds (which is driven by the + // session flag), the paused bubble lands in neither the settled list + // nor the streaming view and vanishes until the next turn. Clearing + // isStreaming here also stops processStream's end-of-stream cleanup + // from re-finalizing the cancelled message while we suspend. + flushPendingUpdates(for: key) + stopFlushTimer(for: key) + + updateState(key) { state in + state.isStreaming = false + state.isThinking = false + state.needsNewMessage = false + state.activeStreamId = nil + state.streamTask = nil + state.activeToolId = nil + state.activeToolInputBuffer = "" + state.textDeltaBuffer = "" + state.pendingToolResults.removeAll() + if let idx = state.messages.indices.reversed().first(where: { + state.messages[$0].role == .assistant && state.messages[$0].isStreaming + }) { + // The user paused this turn — keep the partial assistant bubble + // visible. markStreamInterrupted() clears the message's streaming + // flag and retains in-progress tool calls (flagged as interrupted) + // instead of dropping them; the no-op strip below is told not to + // delete an emptied message. + state.messages[idx].markStreamInterrupted() + if let start = state.streamingStartDate { + state.messages[idx].duration = Date().timeIntervalSince(start) + } + Self.stripNoOpText(at: idx, in: &state.messages, removeIfEmpty: false) + } + state.streamingStartDate = nil + state.inFlightUserAttachments = [] + } + + window.showError = false + window.errorMessage = nil + + if let streamToCancel { + let provider = sessionStates[key]?.agentProvider ?? effectiveModelSelection(in: window).provider + await backend(for: provider).cancel(streamId: streamToCancel) + } + + // Save messages accumulated up to the point of cancellation to disk (prevent data loss). + // The placeholder session (if any) is left in place so partial messages remain visible; + // it will be promoted to the real CLI session id on the next user turn. + if let project = window.selectedProject { + let messages = stateForSession(key).messages + if !messages.isEmpty { + await saveSession(sessionId: key, projectId: project.id, messages: messages) + } + } + } + + func recordStreamingDuration(for key: String) { + guard let start = sessionStates[key]?.streamingStartDate else { return } + let duration = Date().timeIntervalSince(start) + updateState(key) { state in + state.streamingStartDate = nil + if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant }) { + state.messages[idx].duration = duration + } + } + } + + // MARK: - Permission Response + + func respondToPermission(_ request: PermissionRequest, decision: PermissionDecision, in window: WindowState) async { + await permission.respond(toolUseId: request.id, decision: decision) + window.pendingPermissions.removeAll { $0.id == request.id } + mobilePendingRequests.removeValue(forKey: request.id) + if let sessionId = request.sessionId { + broadcastMobileSessionStatus(sessionID: sessionId) + } + } + + // MARK: - AskUserQuestion Response + + /// Deliver the user's answers for an AskUserQuestion tool call via the PreToolUse hook. + /// + /// AskUserQuestion is handled like any other PreToolUse hook: the PermissionServer is + /// holding the HTTP connection open waiting for a decision. We resolve it with `allow` + + /// `updatedInput: {questions, answers: {questionText: }}` so the CLI injects + /// the answers into the tool input and proceeds. + func respondToAskUserQuestion( + toolUseId: String, + answers: [Int: AskUserQuestion.Answer], + in window: WindowState + ) async { + let key = window.currentSessionId ?? window.newSessionKey + + var updatedInput = JSONValue.object([ + "questions": .array([]), + "answers": .object([:]), + ]) + + updateState(key) { state in + for i in state.messages.indices.reversed() { + guard let idx = state.messages[i].toolCallIndex(id: toolUseId), + let toolInput = state.messages[i].blocks[idx].toolCall?.input, + let parsed = AskUserQuestion(input: toolInput) else { continue } + + updatedInput = AskUserQuestion.updatedInputJSON( + originalInput: toolInput, + questions: parsed.questions, + answers: answers + ) + let summary = AskUserQuestion.summary(questions: parsed.questions, answers: answers) + state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) + return + } + } + + window.pendingPermissions.removeAll { $0.id == toolUseId } + let requestSessionId = mobilePendingRequests.removeValue(forKey: toolUseId)?.sessionId + if window.presentedPermissionId == toolUseId { + window.presentedPermissionId = nil + } + if let requestSessionId { + broadcastMobileSessionStatus(sessionID: requestSessionId) + } + broadcastMobileQuestionQueue() + + await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) + } + + /// User dismissed the question sheet without answering — resolve the hook as deny so + /// the CLI does not block, and clear the pending entry from the window queue. + func skipAskUserQuestion(toolUseId: String, in window: WindowState) async { + window.pendingPermissions.removeAll { $0.id == toolUseId } + let requestSessionId = mobilePendingRequests.removeValue(forKey: toolUseId)?.sessionId + if window.presentedPermissionId == toolUseId { + window.presentedPermissionId = nil + } + if let requestSessionId { + broadcastMobileSessionStatus(sessionID: requestSessionId) + } + broadcastMobileQuestionQueue() + await permission.respond(toolUseId: toolUseId, decision: .deny) + } + + /// Apply a question answer that arrived from a paired mobile device. + /// Resolves the CLI hook, mirrors the answer into chat history, and clears + /// the request from every desktop window's queue. An empty `answers` array + /// means the user chose "Skip All Questions" on mobile. + func handleMobileQuestionAnswer(_ payload: QuestionAnswerPayload) async { + let toolUseId = payload.toolUseID + guard let request = mobilePendingRequests[toolUseId] else { return } + + guard !payload.answers.isEmpty, let parsed = AskUserQuestion(input: request.toolInput) else { + // Skip — or a malformed payload we cannot answer: deny the hook. + clearPendingQuestion(toolUseId: toolUseId, sessionId: request.sessionId) + await permission.respond(toolUseId: toolUseId, decision: .deny) + return + } + + var answers: [Int: AskUserQuestion.Answer] = [:] + for entry in payload.answers { + answers[entry.questionIndex] = entry.multiSelect + ? .multi(entry.values) + : .single(entry.values.first ?? "") + } + + let updatedInput = AskUserQuestion.updatedInputJSON( + originalInput: request.toolInput, + questions: parsed.questions, + answers: answers + ) + let summary = AskUserQuestion.summary(questions: parsed.questions, answers: answers) + + if let sessionId = request.sessionId { + updateState(sessionId) { state in + for i in state.messages.indices.reversed() { + guard state.messages[i].toolCallIndex(id: toolUseId) != nil else { continue } + state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) + return + } + } + } + + clearPendingQuestion(toolUseId: toolUseId, sessionId: request.sessionId) + await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) + } + + /// Apply a plan decision that arrived from a paired mobile device. Builds a + /// transient `WindowState` bound to the target session — mirroring + /// `handleMobileUserMessage` — and delegates to `respondToPlanDecision`, + /// which owns all the desktop plan logic (CLI hook release, decision summary + /// into `toolCall.result` + `planDecisionSummaries`, `threadStore` persist, + /// one-shot plan-mode clear, follow-up permission mode, continuation prompt). + /// The updated messages broadcast back through the normal session-update sync. + func handleMobilePlanDecision(_ payload: PlanDecisionPayload) async { + let sessionID = resolveCurrentSessionId(payload.sessionID) + guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { + logger.error("[MobileSync] plan decision for unknown thread=\(payload.sessionID, privacy: .public)") + return + } + let window = WindowState() + window.selectedProject = projects.first(where: { $0.id == summary.projectId }) + window.currentSessionId = sessionID + // `respondToPlanDecision` reads `window.sessionPlanMode` to clear plan + // mode one-shot and `window.sessionPermissionMode` for re-registration — + // mirror the live session state into the fresh window. + if let state = sessionStates[sessionID] { + window.sessionPlanMode = state.planMode + window.sessionPermissionMode = state.permissionMode + } + await respondToPlanDecision( + toolUseId: payload.toolUseID, + action: payload.toDecisionAction(), + in: window + ) + } + + /// Remove a resolved `AskUserQuestion` request from every window's queue and + /// re-broadcast the (now smaller) queue to mobile. + func clearPendingQuestion(toolUseId: String, sessionId: String?) { + for window in registeredWindows() { + window.pendingPermissions.removeAll { $0.id == toolUseId } + if window.presentedPermissionId == toolUseId { + window.presentedPermissionId = nil + } + } + mobilePendingRequests.removeValue(forKey: toolUseId) + if let sessionId { + broadcastMobileSessionStatus(sessionID: sessionId) + } + broadcastMobileQuestionQueue() + } + + // MARK: - Plan Decision Response + + /// Resolve a Claude `ExitPlanMode` tool call based on the user's choice in the plan card. + /// Drives both the PermissionServer hook response (allow/deny + optional reason or + /// follow-up mode change) and the local UI bookkeeping (clear plan-mode pill, update + /// permission chip, mark the tool block decided). + func respondToPlanDecision(toolUseId: String, action: PlanDecisionAction, in window: WindowState) async { + let summary: String + let decision: PermissionDecision + let nextMode: PermissionMode? + + switch action { + case .acceptAsk: + summary = "Accepted with Ask" + decision = .allowAndSetMode(newMode: .default) + nextMode = .default + case .acceptWithEdits: + summary = "Accepted with Edits" + decision = .allowAndSetMode(newMode: .acceptEdits) + nextMode = .acceptEdits + case .acceptAutoApprove: + summary = "Accepted with Auto-approve" + decision = .allowAndSetMode(newMode: .auto) + nextMode = .auto + case .rejectWithFeedback(let reason): + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + summary = trimmed.isEmpty ? "Rejected" : "Rejected: \(trimmed)" + decision = .denyWithReason(reason: trimmed.isEmpty ? "User rejected the plan." : trimmed) + nextMode = nil + case .reject: + summary = "Rejected" + decision = .denyWithReason(reason: "User rejected the plan.") + nextMode = nil + } + + // Record the outcome on the tool block so `PlanCardView` flips from buttons to + // a "decided" status row. This mirrors how AskUserQuestionView reads `toolCall.result`. + // Also write into a sidecar dict that survives CLI-backed session reloads — + // the CLI emits its own follow-up tool_result ("User has approved your plan…") + // that overwrites `ToolCall.result` once the session jsonl is parsed fresh + // from disk, so the in-memory result alone is not reliable. + let key = window.currentSessionId ?? window.newSessionKey + updateState(key) { state in + for i in state.messages.indices.reversed() { + if state.messages[i].toolCallIndex(id: toolUseId) != nil { + state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) + break + } + } + state.planDecisionSummaries[toolUseId] = summary + } + threadStore.setPlanDecision(sessionId: key, toolCallId: toolUseId, summary: summary) + + // Plan-mode is one-shot — clear the pill so the next user turn isn't in plan mode. + // This also triggers a permission re-register (no-op if there's no live CLI sid). + if window.sessionPlanMode { + window.sessionPlanMode = false + updateState(key) { $0.planMode = false } + } + + // Persist the new permission mode in the dropdown when the user opted for a + // follow-up mode change. `setSessionPermissionMode` also re-registers with the + // PermissionServer for the live session. + if let nextMode { + setSessionPermissionMode(nextMode, in: window) + } else { + // Even when no mode change is requested, re-register so the server registry + // reflects the now-cleared plan-mode boolean. + reregisterPermissionMode(in: window) + } + + await permission.respond(toolUseId: toolUseId, decision: decision) + + // When the CLI honors `allowAndSetMode` it continues the same turn — the model + // executes (or revises) the plan inline and the turn ends naturally. In that + // case sending a follow-up prompt would spawn a redundant second turn that + // reports "the work is already done". Only inject a continuation message when + // the turn actually ended without producing any post-plan content, mirroring + // the older CLI behavior where ExitPlanMode terminated the turn outright. + let continuationPrompt = Self.continuationPrompt(for: action) + + if let continuationPrompt { + if let task = sessionStates[key]?.streamTask { + _ = await task.value + } + if turnContinuedAfterPlan(toolUseId: toolUseId, sessionKey: key) { + return + } + await sendPrompt( + continuationPrompt, + skipAppendingUserMessage: true, + in: window + ) + } + } + + /// 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. + func turnContinuedAfterPlan(toolUseId: String, sessionKey: String) -> Bool { + guard let messages = sessionStates[sessionKey]?.messages else { return false } + for messageIdx in messages.indices.reversed() { + guard let planBlockIdx = messages[messageIdx].toolCallIndex(id: toolUseId) else { + continue + } + // 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 + } + + /// Prefixes of result strings written by `respondToPlanDecision`. Sourced from + /// `PlanDecisionAction.userDecisionResultPrefixes` so the chip in chat, the + /// CLI-session reload guard, and the live-stream guard all share one source + /// of truth. + static let planDecisionResultPrefixes: [String] = PlanDecisionAction.userDecisionResultPrefixes + + static func isExitPlanModeCall(_ call: ToolCall) -> Bool { + let n = call.name.lowercased() + return n == "exitplanmode" || n == "exit_plan_mode" + } + + /// Hidden follow-up prompt to send after a plan decision, or nil if the chat + /// should stop. Plain `.reject` returns nil; `.rejectWithFeedback` with empty + /// feedback also returns nil (the user effectively did a plain reject). + static func continuationPrompt(for action: PlanDecisionAction) -> String? { + switch action { + case .acceptAsk, .acceptWithEdits, .acceptAutoApprove: + return "Proceed with the plan." + case .rejectWithFeedback(let reason): + let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + ? nil + : "Revise the plan based on this feedback: \(trimmed)" + case .reject: + return nil + } + } + +} diff --git a/RxCode/App/AppState+Worktree.swift b/RxCode/App/AppState+Worktree.swift new file mode 100644 index 0000000..7bfdcf9 --- /dev/null +++ b/RxCode/App/AppState+Worktree.swift @@ -0,0 +1,466 @@ +import Foundation +import os +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +extension AppState { + // MARK: - Archive + + func archiveSession(_ session: ChatSession, in window: WindowState) async { + await setArchived(session, archived: true, in: window) + } + + func unarchiveSession(_ session: ChatSession, in window: WindowState? = nil) async { + if let window { + await setArchived(session, archived: false, in: window) + } else { + await setArchivedNoWindow(session, archived: false) + } + } + + func setArchived(_ session: ChatSession, archived: Bool, in window: WindowState) async { + // If the chat being archived is currently open in this window, swap to a + // fresh session so the user isn't stranded staring at a now-hidden chat. + if archived, window.currentSessionId == session.id { + detachCurrentStream(in: window) + startNewChat(in: window) + } + await setArchivedNoWindow(session, archived: archived) + } + + func setArchivedNoWindow(_ session: ChatSession, archived: Bool) async { + let now = Date() + if let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) { + allSessionSummaries[si].isArchived = archived + allSessionSummaries[si].archivedAt = archived ? now : nil + threadStore.upsert(allSessionSummaries[si]) + } else { + _ = threadStore.setArchived(id: session.id, archived: archived, at: now) + } + await updateSessionMetadata(session) { s in + s.isArchived = archived + s.archivedAt = archived ? now : nil + } + scheduleMobileSnapshotBroadcast() + } + + /// Retention window (days) after which a branch briefing is purged if its + /// branch hasn't been observed locally or remotely. A branch deleted both + /// places will simply stop being touched and age out. + static let branchBriefingRetentionDays = 30 + + /// Mark a branch as still alive on disk so its briefing isn't garbage- + /// collected. Called from views that have just observed the branch via + /// `git symbolic-ref` or similar. + func touchBranchBriefing(projectId: UUID, branch: String) { + threadStore.touchBranchBriefing(projectId: projectId, branch: branch) + } + + /// Delete branch briefings for branches that haven't been seen for the + /// retention window. Run once at app launch. + func purgeStaleBranchBriefingsIfNeeded() { + let cutoff = Calendar.current.date( + byAdding: .day, + value: -Self.branchBriefingRetentionDays, + to: Date() + ) ?? Date() + let purged = threadStore.purgeStaleBranchBriefings(olderThan: cutoff) + guard !purged.isEmpty else { return } + branchBriefingRevision &+= 1 + logger.info("Purged \(purged.count) stale branch briefings older than \(Self.branchBriefingRetentionDays) days") + } + + /// Apply the auto-archive policy: archive non-pinned chats whose `updatedAt` + /// is older than `archiveRetentionDays`. Run once at app launch. + func autoArchiveExpiredSessionsIfNeeded() { + guard autoArchiveEnabled else { return } + let cutoff = Calendar.current.date( + byAdding: .day, + value: -archiveRetentionDays, + to: Date() + ) ?? Date() + let archivedIds = threadStore.archiveStale(olderThan: cutoff) + guard !archivedIds.isEmpty else { return } + let idSet = Set(archivedIds) + let now = Date() + for idx in allSessionSummaries.indices where idSet.contains(allSessionSummaries[idx].id) { + allSessionSummaries[idx].isArchived = true + allSessionSummaries[idx].archivedAt = now + } + logger.info("Auto-archived \(archivedIds.count) chats older than \(self.archiveRetentionDays) days") + } + + /// Persist a metadata-only edit (title, pin, etc.) routing by session + /// origin. cliBacked sessions go to the sidecar; legacy sessions need the + /// full message log to be re-saved alongside the change. + /// `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. + /// Subsequent CLI invocations for this session will run in the worktree. + func attachWorktree(branch: String, in window: WindowState) async throws { + guard let project = window.selectedProject else { + throw AppError.noProjectSelected + } + let baseRepo = URL(fileURLWithPath: project.path) + let info = try await GitWorktreeService.shared.createWorktree( + baseRepo: baseRepo, + branch: branch + ) + + // New-chat view: no session yet. Park the worktree on the window so + // it gets applied when sendPrompt allocates a session id. + guard let sessionId = window.currentSessionId else { + window.pendingWorktreePath = info.path.path + window.pendingWorktreeBranch = info.branch + return + } + + // Update in-memory state + sessionStates[sessionId, default: SessionStreamState()].worktreePath = info.path.path + sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = info.branch + if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[idx].worktreePath = info.path.path + allSessionSummaries[idx].worktreeBranch = info.branch + threadStore.upsert(allSessionSummaries[idx]) + } + // Persist via sidecar meta + let snap = allSessionSummaries.first(where: { $0.id == sessionId }) + let fallbackProvider = defaultModelSelection(for: project).provider + let updated = (snap ?? ChatSession.Summary( + id: sessionId, projectId: project.id, title: ChatSession.defaultTitle, + createdAt: Date(), updatedAt: Date(), isPinned: false, + agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider, + worktreePath: info.path.path, worktreeBranch: info.branch + )).makeSession() + await updateSessionMetadata(updated) { s in + s.worktreePath = info.path.path + s.worktreeBranch = info.branch + } + } + + /// Switch the chat to an existing branch. + /// + /// If the branch is already attached to a linked worktree, point the + /// session at that worktree. Otherwise run `git checkout` in the project + /// root and clear the session's worktree pointer. + func switchToExistingBranch(_ branch: String, in window: WindowState) async throws { + guard let project = window.selectedProject else { + throw AppError.noProjectSelected + } + let baseRepo = URL(fileURLWithPath: project.path) + + let existingWorktree: GitWorktreeService.WorktreeInfo? = await { + guard let list = try? await GitWorktreeService.shared.listWorktrees(baseRepo: baseRepo) else { + return nil + } + // The main repo also appears in `worktree list`; skip it so the + // project root takes the plain-checkout path. + return list.first { $0.branch == branch && $0.path.standardizedFileURL != baseRepo.standardizedFileURL } + }() + + let newPath: String? + let newBranch: String? + if let existingWorktree { + newPath = existingWorktree.path.path + newBranch = existingWorktree.branch + } else { + if let err = await GitHelper.checkout(branch: branch, at: project.path) { + throw GitWorktreeService.WorktreeError.gitFailed(err) + } + newPath = nil + newBranch = nil + } + + guard let sessionId = window.currentSessionId else { + window.pendingWorktreePath = newPath + window.pendingWorktreeBranch = newBranch + return + } + + sessionStates[sessionId, default: SessionStreamState()].worktreePath = newPath + sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = newBranch + if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[idx].worktreePath = newPath + allSessionSummaries[idx].worktreeBranch = newBranch + threadStore.upsert(allSessionSummaries[idx]) + } + if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { + await updateSessionMetadata(snap.makeSession()) { s in + s.worktreePath = newPath + s.worktreeBranch = newBranch + } + } + } + + /// Remove the worktree associated with the session (if any). + /// `force = true` removes even with uncommitted changes. + func detachWorktree(in window: WindowState, force: Bool = false) async throws { + guard let sessionId = window.currentSessionId else { return } + let path = sessionStates[sessionId]?.worktreePath + ?? allSessionSummaries.first(where: { $0.id == sessionId })?.worktreePath + guard let path else { return } + try await GitWorktreeService.shared.removeWorktree(URL(fileURLWithPath: path), force: force) + sessionStates[sessionId]?.worktreePath = nil + sessionStates[sessionId]?.worktreeBranch = nil + if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[idx].worktreePath = nil + allSessionSummaries[idx].worktreeBranch = nil + threadStore.upsert(allSessionSummaries[idx]) + } + if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { + await updateSessionMetadata(snap.makeSession()) { s in + s.worktreePath = nil + s.worktreeBranch = nil + } + } + } + + /// Returns the current effective working directory for the active chat — + /// either the chat's worktree (if attached) or the project path. + func effectiveCwd(in window: WindowState) -> String? { + guard let project = window.selectedProject else { return nil } + if let sid = window.currentSessionId { + if let p = sessionStates[sid]?.worktreePath { return p } + if let p = allSessionSummaries.first(where: { $0.id == sid })?.worktreePath { return p } + } + return project.path + } + + func updateSessionMetadata( + _ session: ChatSession, + persistTitle: Bool = false, + mutate: (inout ChatSession) -> Void + ) async { + let summary = allSessionSummaries.first(where: { $0.id == session.id }) ?? session.summary + var updated: ChatSession = switch summary.origin { + case .cliBacked: + summary.makeSession() + case .legacyRxCode, .codexAppServer, .acpAgent: + persistence.loadLegacySessionSync(projectId: session.projectId, sessionId: session.id) ?? session + } + mutate(&updated) + do { try await persistence.saveSession(updated, persistTitle: persistTitle) } + catch { logger.error("Failed to save session metadata: \(error.localizedDescription)") } + } + + func renameProject(_ project: Project, to newName: String) async { + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let index = projects.firstIndex(where: { $0.id == project.id }) else { return } + projects[index].name = trimmed + do { + try await persistence.saveProjects(projects) + } catch { + logger.error("Failed to save projects after rename: \(error.localizedDescription)") + } + } + + func deleteProject(_ project: Project, in window: WindowState) async { + // Switch away if the deleted project is currently selected + if window.selectedProject?.id == project.id { + let next = projects.first(where: { $0.id != project.id }) + if let next { + selectProject(next, in: window) + } else { + window.selectedProject = nil + window.currentSessionId = nil + } + } + + // Cascade: delete each session's stored messages (CLI jsonl, meta, + // legacy json) before discarding the project itself. Without this the + // jsonls remain on disk under the project's cwd and would resurface as + // orphan sessions if the same path is added back as a project later. + let projectSummaries = allSessionSummaries.filter { $0.projectId == project.id } + for summary in projectSummaries { + let cwd = summary.worktreePath ?? project.path + do { + try await persistence.deleteSession( + projectId: summary.projectId, + sessionId: summary.id, + origin: summary.origin, + cwd: cwd + ) + } catch { + logger.error("Failed to delete session \(summary.id) on project delete: \(error.localizedDescription)") + } + sessionStates.removeValue(forKey: summary.id) + } + + // Remove all in-memory session summaries for this project + threadStore.deleteAll(projectId: project.id) + let projectId = project.id + Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } + Task.detached(priority: .utility) { [memoryService] in await memoryService.deleteAll(projectId: projectId) } + allSessionSummaries.removeAll { $0.projectId == project.id } + + // Remove from projects list and persist + projects.removeAll { $0.id == project.id } + do { + try await persistence.saveProjects(projects) + } catch { + logger.error("Failed to save projects after deletion: \(error.localizedDescription)") + } + } + + func deleteSession(_ session: ChatSession, in window: WindowState) async { + if window.currentSessionId == session.id { + detachCurrentStream(in: window) + startNewChat(in: window) + } + let summary = allSessionSummaries.first(where: { $0.id == session.id }) + let origin = summary?.origin ?? session.origin + // Prefer the worktreePath used while writing the jsonl — otherwise the + // CLI jsonl (stored under that cwd) is orphaned on disk and resurrects + // on next reload. Fall back to the project's path, then the session's. + let cwd = summary?.worktreePath + ?? session.worktreePath + ?? projects.first(where: { $0.id == session.projectId })?.path + do { + try await persistence.deleteSession(projectId: session.projectId, sessionId: session.id, origin: origin, cwd: cwd) + } catch { + logger.error("Failed to delete session: \(error.localizedDescription)") + } + allSessionSummaries.removeAll { $0.id == session.id } + threadStore.delete(id: session.id) + let deletedId = session.id + Task.detached(priority: .utility) { [searchService] in await searchService.removeThread(id: deletedId) } + sessionStates.removeValue(forKey: session.id) + } + + func deleteAllSessions(projectId: UUID? = nil, archivedOnly: Bool = false, in window: WindowState) async { + var toDelete: [ChatSession.Summary] + if let projectId { + toDelete = allSessionSummaries.filter { $0.projectId == projectId } + } else { + toDelete = allSessionSummaries + } + if archivedOnly { + toDelete = toDelete.filter { $0.isArchived } + } + let ids = Set(toDelete.map(\.id)) + + // Only disrupt the current window's stream if its session is actually + // being deleted — otherwise a project-scoped delete would clobber an + // unrelated streaming session. + if let currentId = window.currentSessionId, ids.contains(currentId) { + detachCurrentStream(in: window) + startNewChat(in: window) + } + + for summary in toDelete { + let cwd = summary.worktreePath + ?? projects.first(where: { $0.id == summary.projectId })?.path + do { + try await persistence.deleteSession( + projectId: summary.projectId, + sessionId: summary.id, + origin: summary.origin, + cwd: cwd + ) + } catch { + logger.error("Failed to delete session \(summary.id): \(error.localizedDescription)") + } + } + + allSessionSummaries.removeAll { ids.contains($0.id) } + if archivedOnly { + for id in ids { + threadStore.delete(id: id) + } + let snapshotIds = ids + Task.detached(priority: .utility) { [searchService] in + for id in snapshotIds { await searchService.removeThread(id: id) } + } + } else { + threadStore.deleteAll(projectId: projectId) + if let projectId { + Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } + } else { + let snapshotIds = ids + Task.detached(priority: .utility) { [searchService] in + for id in snapshotIds { await searchService.removeThread(id: id) } + } + } + } + for id in ids { + sessionStates.removeValue(forKey: id) + } + } + + func selectSession(id: String, in window: WindowState) { + logger.info("[SelectSession] click sid=\(id, privacy: .public) currentSid=\(window.currentSessionId ?? "", privacy: .public) selectedProject=\(window.selectedProject?.id.uuidString ?? "", privacy: .public) summariesCount=\(self.allSessionSummaries.count)") + guard window.currentSessionId != id else { + if window.showingBriefing { + window.showingBriefing = false + window.requestInputFocus = true + logger.info("[SelectSession] same sid, leaving briefing sid=\(id, privacy: .public)") + } else { + logger.info("[SelectSession] no-op: already current sid=\(id, privacy: .public)") + } + return + } + + window.cancelSessionSwitchTask() + + if let summary = allSessionSummaries.first(where: { $0.id == 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) + window.requestInputFocus = true + window.setSessionSwitchTask(Task { + guard !Task.isCancelled else { return } + await didSwitchToSession(session) + }) + return + } + + // 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 { + logger.error("[SelectSession] summary or project missing for sid=\(id, privacy: .public)") + return + } + + logger.info("[SelectSession] cross-project switch sid=\(id, privacy: .public) toProject=\(project.id.uuidString, privacy: .public)") + window.setSessionSwitchTask(Task { [weak self] in + guard let self else { return } + guard !Task.isCancelled else { return } + selectProject(project, in: window) + guard !Task.isCancelled else { return } + 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) + { + logger.info("[SelectSession] cross-project preload ok sid=\(id, privacy: .public) messages=\(full.messages.count)") + switchToSession(full, messages: full.messages, in: window) + } else { + logger.info("[SelectSession] cross-project preload empty sid=\(id, privacy: .public)") + switchToSession(session, in: window) + } + window.requestInputFocus = true + await didSwitchToSession(session) + } + }) + } + + func addProject(_ project: Project) { + guard !projects.contains(where: { $0.path == project.path }) else { return } + projects.append(project) + Task { + do { try await persistence.saveProjects(projects) } + catch { logger.error("Failed to save projects: \(error.localizedDescription)") } + } + } + +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index fef3449..75e327d 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -131,7 +131,7 @@ enum SummarizationProvider: String, CaseIterable, Identifiable { final class AppState { // MARK: - Logger - private let logger = Logger(subsystem: "com.claudework", category: "AppState") + let logger = Logger(subsystem: "com.claudework", category: "AppState") // MARK: - Projects (shared) @@ -147,7 +147,7 @@ final class AppState { /// advanced by `compact_boundary`) to the current sid it was swapped to. /// Lets long-running async tasks (LLM title generation) resolve the current /// id after the swap that happens mid-stream in `processStream`. - private var sessionIdRedirect: [String: String] = [:] + var sessionIdRedirect: [String: String] = [:] // MARK: - Stream Completion Tracking (cross-project MCP) @@ -161,7 +161,7 @@ final class AppState { /// Completed streams whose owners (callers of `awaitStreamCompletion`) /// have not yet picked up the result. Keyed by `streamId`. - private var pendingStreamCompletions: [UUID: StreamCompletion] = [:] + var pendingStreamCompletions: [UUID: StreamCompletion] = [:] // MARK: - Session Summaries (shared — lightweight metadata for all projects) @@ -174,7 +174,7 @@ final class AppState { /// Pending permission/question prompts keyed by hook id. This mirrors the /// per-window queues so mobile thread rows can show the same attention state. - private var mobilePendingRequests: [String: PermissionRequest] = [:] + var mobilePendingRequests: [String: PermissionRequest] = [:] // MARK: - Theme @@ -227,215 +227,6 @@ final class AppState { messageFontSizeAdjustment -= 1 } - // MARK: - Model - - static let availableModels = ["default", "best", "opus", "opus[1m]", "opusplan", "sonnet", "sonnet[1m]", "haiku"] - static let fallbackCodexModels = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"] - nonisolated static let defaultOpenAISummarizationEndpoint = "https://api.openai.com/v1" - private static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization" - private static let openAISummarizationKeychainAccount = "apiKey" - - static var availableClaudeModels: [AgentModel] { - availableModels.map { - AgentModel(provider: .claudeCode, id: $0, displayName: modelDisplayName($0), description: modelDescription($0)) - } - } - - static func availableCodexModels(_ discovered: [AgentModel]) -> [AgentModel] { - if !discovered.isEmpty { return discovered } - return fallbackCodexModels.map { - AgentModel(provider: .codex, id: $0, displayName: modelDisplayName($0, provider: .codex), description: modelDescription($0, provider: .codex)) - } - } - - 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 { - modelDisplayName(model, provider: .claudeCode) - } - - static func modelDisplayName(_ model: String, provider: AgentProvider) -> String { - if provider == .codex { - return model - .replacingOccurrences(of: "-", with: " ") - .split(separator: " ") - .map { part in - part.uppercased().hasPrefix("GPT") ? part.uppercased() : part.capitalized - } - .joined(separator: " ") - } - switch model { - case "default": return "Default" - case "best": return "Best" - case "opus": return "Opus" - case "opus[1m]": return "Opus 1M" - case "opusplan": return "Opus Plan" - case "sonnet": return "Sonnet" - case "sonnet[1m]": return "Sonnet 1M" - case "haiku": return "Haiku" - default: return model.capitalized - } - } - - static func modelDescription(_ model: String) -> String { - modelDescription(model, provider: .claudeCode) - } - - static func modelDescription(_ model: String, provider: AgentProvider) -> String { - if provider == .codex { - switch model { - case "gpt-5.4": return "Balanced Codex model for everyday coding." - case "gpt-5.4-mini": return "Fast Codex model for lighter coding tasks." - case "gpt-5.3-codex": return "Codex-optimized coding model." - default: return "Codex model served by the Codex app-server." - } - } - 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 "sonnet[1m]": key = "model.desc.sonnet1m" - 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 .bypassPermissions: key = "perm.desc.bypassPermissions" - } - return NSLocalizedString(key, comment: "") - } - - static func effortDescription(_ effort: String) -> String { - let key: String - switch effort { - 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" - default: return "" - } - return NSLocalizedString(key, comment: "") - } var selectedModel: String = UserDefaults.standard.string(forKey: "selectedModel") ?? "opus" { didSet { UserDefaults.standard.set(selectedModel, forKey: "selectedModel") } @@ -578,7 +369,7 @@ final class AppState { // MARK: - Attachment Auto-Preview Settings - private static let autoPreviewSettingsKey = "attachmentAutoPreviewSettings" + static let autoPreviewSettingsKey = "attachmentAutoPreviewSettings" var autoPreviewSettings: AttachmentAutoPreviewSettings = { guard let data = UserDefaults.standard.data(forKey: AppState.autoPreviewSettingsKey), @@ -606,7 +397,7 @@ final class AppState { /// signed in to Claude Code). var latestRateLimitUsage: RateLimitUsage? var latestCodexRateLimitUsage: RateLimitUsage? - @ObservationIgnored private var rateLimitUsageRefreshTasks: [AgentProvider: Task] = [:] + @ObservationIgnored var rateLimitUsageRefreshTasks: [AgentProvider: Task] = [:] /// Sessions currently streaming, anywhere across all windows. var inProgressSessionCount: Int { @@ -663,7 +454,7 @@ final class AppState { return cachedRateLimitUsage(for: provider) } - private func storeRateLimitUsage(_ usage: RateLimitUsage, for provider: AgentProvider) { + func storeRateLimitUsage(_ usage: RateLimitUsage, for provider: AgentProvider) { switch provider { case .claudeCode: latestRateLimitUsage = usage @@ -734,13 +525,13 @@ final class AppState { (openProjectWindowCounts[projectId] ?? 0) > 0 } - private func isModelAvailable(_ model: String, provider: AgentProvider) -> Bool { + func isModelAvailable(_ model: String, provider: AgentProvider) -> Bool { availableAgentModelSections() .flatMap(\.models) .contains { $0.provider == provider && $0.id == model } } - private func projectDefaultModelSelection(for project: Project?) -> (provider: AgentProvider, model: String)? { + func projectDefaultModelSelection(for project: Project?) -> (provider: AgentProvider, model: String)? { guard let project, let provider = project.lastAgentProvider, let model = project.lastModel, @@ -765,7 +556,7 @@ final class AppState { return defaultModelSelection(for: window.selectedProject) } - private func rememberProjectModelSelection(_ model: String, provider: AgentProvider, in window: WindowState) { + func rememberProjectModelSelection(_ model: String, provider: AgentProvider, in window: WindowState) { guard let project = window.selectedProject, let index = projects.firstIndex(where: { $0.id == project.id }) else { @@ -873,7 +664,7 @@ final class AppState { /// Tell the PermissionServer the latest effective permission mode for the live CLI session, /// if any. This is what makes mid-conversation mode changes (Ask → Auto, plan toggle, etc.) /// affect the very next tool call without restarting the stream. - private func reregisterPermissionMode(in window: WindowState) { + func reregisterPermissionMode(in window: WindowState) { guard let sid = window.currentSessionId, let projectPath = window.selectedProject?.path else { return } // Use the dropdown value (ignore plan toggle) so an explicit Auto choice @@ -978,34 +769,34 @@ final class AppState { let runService = RunService() let localWebProxy = LocalWebProxyServer() let ideMCPServer = IDEMCPServer() - private var mobileSyncObservers: [NSObjectProtocol] = [] + var mobileSyncObservers: [NSObjectProtocol] = [] /// Weak refs to every `WindowState` that's been wired up via `setupChatBridge`. /// Used by AppState-driven queue maintenance (e.g. `flushNextQueuedMessageIfNeeded`) /// to scrub stale entries out of each window's in-memory queue mirror. - private struct WeakWindowRef { weak var window: WindowState? } - private var liveWindowRefs: [WeakWindowRef] = [] + struct WeakWindowRef { weak var window: WindowState? } + var liveWindowRefs: [WeakWindowRef] = [] - private func registerLiveWindow(_ window: WindowState) { + func registerLiveWindow(_ window: WindowState) { liveWindowRefs.removeAll { $0.window == nil || $0.window === window } liveWindowRefs.append(WeakWindowRef(window: window)) } - private func registeredWindows() -> [WindowState] { + func registeredWindows() -> [WindowState] { liveWindowRefs.removeAll { $0.window == nil } return liveWindowRefs.compactMap(\.window) } - private var mobileSnapshotBroadcastTask: Task? - private var lastBroadcastRunTaskSnapshots: [UUID: MobileRunTaskSnapshot] = [:] + var mobileSnapshotBroadcastTask: Task? + var lastBroadcastRunTaskSnapshots: [UUID: MobileRunTaskSnapshot] = [:] /// Worktrees freshly created by a mobile "create branch" request, keyed by /// project. Consumed by the next mobile new-session request for the same /// project so the thread spawns into the new worktree. - private struct MobilePendingWorktree { + struct MobilePendingWorktree { let path: String let branch: String } - private var mobilePendingWorktrees: [UUID: MobilePendingWorktree] = [:] + var mobilePendingWorktrees: [UUID: MobilePendingWorktree] = [:] // MARK: - Run Profiles @@ -1042,7 +833,7 @@ final class AppState { /// Disk-persisted draft queues loaded at init. Hydrated into each /// `WindowState.draftQueues` when the window is initialized so messages /// queued during a prior run reappear in their session's input bar. - private var persistedQueues: [String: [QueuedMessage]] = [:] + var persistedQueues: [String: [QueuedMessage]] = [:] // MARK: - MCP State @@ -1053,10 +844,10 @@ final class AppState { var mcpInFlightProbes: Set = [] var mcpIsLoading: Bool = false var mcpListError: String? - private var mcpPeriodicProbeTask: Task? + var mcpPeriodicProbeTask: Task? /// 5 minutes — balances disconnect-detection latency against probe cost /// (each tick spawns one stdio subprocess per stdio server). - private static let mcpPeriodicProbeInterval: UInt64 = 300 * 1_000_000_000 + static let mcpPeriodicProbeInterval: UInt64 = 300 * 1_000_000_000 /// Last project selected in any window — used to scope Local / Project MCP rows /// in the (window-less) Settings sheet. @@ -1113,7305 +904,24 @@ final class AppState { setupMobileSyncBridge() } - private func setupMobileSyncBridge() { - let center = NotificationCenter.default - let snapshotObserver = center.addObserver( - forName: .mobileSyncSnapshotRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String else { return } - let activeSessionID = notification.userInfo?["activeSessionID"] as? String - Task { @MainActor [weak self] in - await self?.sendMobileSnapshot(toHex: fromHex, activeSessionID: activeSessionID) - } - } - mobileSyncObservers.append(snapshotObserver) - - let settingsObserver = center.addObserver( - forName: .mobileSyncSettingsUpdateReceived, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let update = notification.userInfo?["payload"] as? MobileSettingsUpdatePayload - else { return } - Task { @MainActor [weak self] in - self?.applyMobileSettingsUpdate(update) - await self?.sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) - } - } - mobileSyncObservers.append(settingsObserver) - - let userMessageObserver = center.addObserver( - forName: .mobileSyncUserMessageReceived, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let message = notification.userInfo?["payload"] as? UserMessagePayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileUserMessage(message, fromHex: fromHex) - } - } - mobileSyncObservers.append(userMessageObserver) - - let cancelStreamObserver = center.addObserver( - forName: .mobileSyncCancelStreamRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let cancel = notification.userInfo?["payload"] as? CancelStreamPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileCancelStream(cancel) - } - } - mobileSyncObservers.append(cancelStreamObserver) - - let removeQueuedObserver = center.addObserver( - forName: .mobileSyncRemoveQueuedRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let payload = notification.userInfo?["payload"] as? RemoveQueuedMessagePayload - else { return } - Task { @MainActor [weak self] in - self?.handleMobileRemoveQueuedMessage(payload) - } - } - mobileSyncObservers.append(removeQueuedObserver) - - let newSessionObserver = center.addObserver( - forName: .mobileSyncNewSessionRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? NewSessionRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileNewSessionRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(newSessionObserver) - - let threadActionObserver = center.addObserver( - forName: .mobileSyncThreadActionRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? ThreadActionRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileThreadActionRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(threadActionObserver) - - let loadMoreObserver = center.addObserver( - forName: .mobileSyncLoadMoreMessagesRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? LoadMoreMessagesRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileLoadMoreMessages(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(loadMoreObserver) - - let searchObserver = center.addObserver( - forName: .mobileSyncSearchRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? SearchRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileSearchRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(searchObserver) - - let threadChangesObserver = center.addObserver( - forName: .mobileSyncThreadChangesRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? ThreadChangesRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileThreadChangesRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(threadChangesObserver) - - let branchOpObserver = center.addObserver( - forName: .mobileSyncBranchOpRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? BranchOpRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileBranchOpRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(branchOpObserver) - - let folderTreeObserver = center.addObserver( - forName: .mobileSyncFolderTreeRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? FolderTreeRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileFolderTreeRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(folderTreeObserver) - - let createProjectObserver = center.addObserver( - forName: .mobileSyncCreateProjectRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? CreateProjectRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileCreateProjectRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(createProjectObserver) - - let runProfileMutationObserver = center.addObserver( - forName: .mobileSyncRunProfileMutationRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? RunProfileMutationRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileRunProfileMutation(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(runProfileMutationObserver) - - let runProfileRunObserver = center.addObserver( - forName: .mobileSyncRunProfileRunRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? RunProfileRunRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileRunProfileRun(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(runProfileRunObserver) - - let runProfileStopObserver = center.addObserver( - forName: .mobileSyncRunProfileStopRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? RunProfileStopRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileRunProfileStop(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(runProfileStopObserver) - - let questionAnswerObserver = center.addObserver( - forName: .mobileSyncQuestionAnswerReceived, - object: nil, - queue: nil - ) { [weak self] notification in - guard let payload = notification.userInfo?["payload"] as? QuestionAnswerPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileQuestionAnswer(payload) - } - } - mobileSyncObservers.append(questionAnswerObserver) - - let planDecisionObserver = center.addObserver( - forName: .mobileSyncPlanDecisionReceived, - object: nil, - queue: nil - ) { [weak self] notification in - guard let payload = notification.userInfo?["payload"] as? PlanDecisionPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobilePlanDecision(payload) - } - } - mobileSyncObservers.append(planDecisionObserver) - - let skillCatalogObserver = center.addObserver( - forName: .mobileSyncSkillCatalogRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? SkillCatalogRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileSkillCatalogRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(skillCatalogObserver) - - let skillMutationObserver = center.addObserver( - forName: .mobileSyncSkillMutationRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? SkillMutationRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileSkillMutationRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(skillMutationObserver) - - let skillSourceMutationObserver = center.addObserver( - forName: .mobileSyncSkillSourceMutationRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? SkillSourceMutationRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileSkillSourceMutationRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(skillSourceMutationObserver) - - let acpRegistryObserver = center.addObserver( - forName: .mobileSyncACPRegistryRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? ACPRegistryRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileACPRegistryRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(acpRegistryObserver) - - let acpMutationObserver = center.addObserver( - forName: .mobileSyncACPMutationRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? ACPMutationRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileACPMutationRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(acpMutationObserver) - - let mcpConfigObserver = center.addObserver( - forName: .mobileSyncMCPConfigRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? MCPConfigRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileMCPConfigRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(mcpConfigObserver) - - let mcpMutationObserver = center.addObserver( - forName: .mobileSyncMCPMutationRequested, - object: nil, - queue: nil - ) { [weak self] notification in - guard let fromHex = notification.userInfo?["from"] as? String, - let request = notification.userInfo?["payload"] as? MCPMutationRequestPayload - else { return } - Task { @MainActor [weak self] in - await self?.handleMobileMCPMutationRequest(request, fromHex: fromHex) - } - } - mobileSyncObservers.append(mcpMutationObserver) - - observeMobileSnapshotInputs() - } - - private func observeMobileSnapshotInputs() { - withObservationTracking { - _ = selectedAgentProvider - _ = selectedModel - _ = selectedACPClientId - _ = selectedEffort - _ = permissionMode - _ = notificationsEnabled - _ = focusMode - _ = autoArchiveEnabled - _ = archiveRetentionDays - _ = autoPreviewSettings - _ = branchBriefingRevision - _ = threadSummaryRevision - _ = projects.count - _ = allSessionSummaries.count - _ = latestRateLimitUsage - _ = latestCodexRateLimitUsage - _ = runProfilesByProject.count - } onChange: { - Task { @MainActor [weak self] in - self?.scheduleMobileSnapshotBroadcast() - self?.observeMobileSnapshotInputs() - } - } - } - - private func handleMobileNewSessionRequest(_ request: NewSessionRequestPayload, fromHex: String) async { - guard let project = projects.first(where: { $0.id == request.projectID }) else { - logger.error("[MobileSync] new thread requested for unknown project=\(request.projectID.uuidString, privacy: .public)") - await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) - return - } - - let initialText = request.initialText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !initialText.isEmpty else { - let sessionID = createMobilePlaceholderSession(project: project, requestID: request.clientRequestID) - await sendMobileSnapshot(toHex: fromHex, activeSessionID: sessionID) - return - } - - let window = WindowState() - window.selectedProject = project - // Per-thread agent config captured in the mobile new-thread sheet. These - // session-scoped fields win over the project/global defaults in - // `effectiveModelSelection`, so the thread runs on exactly the chosen - // model — and starts in plan mode when requested. Carrying the config in - // the request also removes the `settings_update`/`newSessionRequest` - // ordering race. - if let provider = request.selectedAgentProvider { - window.sessionAgentProvider = provider - } - if let model = request.selectedModel, !model.isEmpty { - window.sessionModel = model - } - if let effort = request.selectedEffort, !effort.isEmpty, effort != "auto" { - window.sessionEffort = effort - } - if let mode = request.permissionMode { - window.sessionPermissionMode = mode - } - window.sessionPlanMode = request.planMode ?? false - if let pending = mobilePendingWorktrees.removeValue(forKey: project.id) { - window.pendingWorktreePath = pending.path - window.pendingWorktreeBranch = pending.branch - } - _ = await sendPrompt(initialText, displayText: initialText, in: window) - await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) - } - - /// Apply a mobile-initiated lifecycle action (rename / archive / unarchive / - /// delete) to an existing thread, then push a fresh snapshot back so the - /// requesting device reconciles immediately. The action mutators each - /// schedule their own broadcast for the remaining paired devices. - private func handleMobileThreadActionRequest(_ request: ThreadActionRequestPayload, fromHex: String) async { - // The mobile may hold a session id the CLI has since advanced - // (pending-→real swap, or a compaction boundary). Follow the redirect - // chain so the action lands on the live thread. - let sessionID = resolveCurrentSessionId(request.sessionID) - guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { - logger.error("[MobileSync] thread action=\(request.action.rawValue, privacy: .public) for unknown thread=\(request.sessionID, privacy: .public)") - await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) - return - } - - logger.info("[MobileSync] applying thread action=\(request.action.rawValue, privacy: .public) thread=\(sessionID, privacy: .public)") - - let session = summary.makeSession() - // Mobile actions aren't tied to a desktop window; a fresh WindowState - // keeps the data-layer mutators happy without disturbing open windows. - let window = WindowState() - - switch request.action { - case .rename: - let title = request.newTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !title.isEmpty else { break } - await renameSession(session, to: title) - case .archive: - await archiveSession(session, in: window) - case .unarchive: - await unarchiveSession(session, in: window) - case .delete: - await deleteSession(session, in: window) - } - - await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) - } - - private func handleMobileBranchOpRequest(_ request: BranchOpRequestPayload, fromHex: String) async { - guard let project = projects.first(where: { $0.id == request.projectID }) else { - await replyBranchOpResult( - request: request, - ok: false, - errorMessage: "Project not found on desktop.", - toHex: fromHex - ) - return - } - - let trimmed = request.branch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - await replyBranchOpResult( - request: request, - ok: false, - errorMessage: "Branch name is required.", - toHex: fromHex - ) - return - } - - let window = WindowState() - window.selectedProject = project - - do { - switch request.operation { - case .switchExisting: - try await switchToExistingBranch(trimmed, in: window) - case .createNew: - try await attachWorktree(branch: trimmed, in: window) - if let path = window.pendingWorktreePath, - let branch = window.pendingWorktreeBranch - { - mobilePendingWorktrees[project.id] = MobilePendingWorktree(path: path, branch: branch) - } - } - } catch { - await replyBranchOpResult( - request: request, - ok: false, - errorMessage: branchOpErrorMessage(error), - toHex: fromHex - ) - return - } - - await replyBranchOpResult(request: request, ok: true, errorMessage: nil, toHex: fromHex) - scheduleMobileSnapshotBroadcast() - } - - private func replyBranchOpResult( - request: BranchOpRequestPayload, - ok: Bool, - errorMessage: String?, - toHex hex: String - ) async { - let result = BranchOpResultPayload( - clientRequestID: request.clientRequestID, - projectID: request.projectID, - operation: request.operation, - branch: request.branch, - ok: ok, - errorMessage: errorMessage - ) - await MobileSyncService.shared.send(.branchOpResult(result), toHex: hex) - } - - private func branchOpErrorMessage(_ error: Error) -> String { - if let werr = error as? GitWorktreeService.WorktreeError { - return werr.description - } - return error.localizedDescription - } - - private func handleMobileFolderTreeRequest(_ request: FolderTreeRequestPayload, fromHex: String) async { - let result: FolderTreeResultPayload - do { - let root = try mobileFolderTreeRoot(for: request) - result = FolderTreeResultPayload( - clientRequestID: request.clientRequestID, - requestedPath: request.path, - ok: true, - root: root - ) - } catch { - result = FolderTreeResultPayload( - clientRequestID: request.clientRequestID, - requestedPath: request.path, - ok: false, - errorMessage: mobileFolderErrorMessage(error) - ) - } - await MobileSyncService.shared.send(.folderTreeResult(result), toHex: fromHex) - } - - private func handleMobileCreateProjectRequest(_ request: CreateProjectRequestPayload, fromHex: String) async { - let trimmedPath = request.path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPath.isEmpty else { - await replyCreateProjectResult( - requestID: request.clientRequestID, - ok: false, - project: nil, - errorMessage: "Folder path is required.", - toHex: fromHex - ) - return - } - - let url = URL(fileURLWithPath: trimmedPath).standardizedFileURL - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue - else { - await replyCreateProjectResult( - requestID: request.clientRequestID, - ok: false, - project: nil, - errorMessage: "Folder does not exist on the desktop.", - toHex: fromHex - ) - return - } - - if projects.first(where: { $0.path == url.path }) == nil { - let isGitRepo = FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path) - let gitHubRepo = isGitRepo ? detectGitHubOwnerRepo(at: url.path) : nil - await addProject(name: url.lastPathComponent, path: url.path, gitHubRepo: gitHubRepo) - } - - guard let project = projects.first(where: { $0.path == url.path }) else { - await replyCreateProjectResult( - requestID: request.clientRequestID, - ok: false, - project: nil, - errorMessage: "Failed to add project on the desktop.", - toHex: fromHex - ) - return - } - - await replyCreateProjectResult( - requestID: request.clientRequestID, - ok: true, - project: project, - errorMessage: nil, - toHex: fromHex - ) - await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) - scheduleMobileSnapshotBroadcast() - } - - private func replyCreateProjectResult( - requestID: UUID, - ok: Bool, - project: Project?, - errorMessage: String?, - toHex hex: String - ) async { - let result = CreateProjectResultPayload( - clientRequestID: requestID, - ok: ok, - project: project, - errorMessage: errorMessage - ) - await MobileSyncService.shared.send(.createProjectResult(result), toHex: hex) - } - - private func handleMobileRunProfileMutation( - _ request: RunProfileMutationRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling run profile mutation operation=\(request.operation.rawValue, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - guard projects.contains(where: { $0.id == request.projectID }) else { - logger.error("[MobileSync] run profile mutation rejected unknown project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Project not found on desktop.", - task: nil, - toHex: fromHex - ) - return - } - - await ensureRunProfilesLoaded(for: request.projectID) - var profiles = runProfiles(for: request.projectID) - let now = Date() - - switch request.operation { - case .upsert: - guard var profile = request.profile else { - logger.error("[MobileSync] run profile upsert rejected missing payload project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Profile payload is missing.", - task: nil, - toHex: fromHex - ) - return - } - profile.projectId = request.projectID - profile.updatedAt = now - if let idx = profiles.firstIndex(where: { $0.id == profile.id }) { - profiles[idx] = profile - } else { - profile.createdAt = now - profiles.append(profile) - } - case .delete: - guard let profileID = request.profileID else { - logger.error("[MobileSync] run profile delete rejected missing profile id project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Profile id is missing.", - task: nil, - toHex: fromHex - ) - return - } - profiles.removeAll { $0.id == profileID } - } - - setRunProfiles(profiles, for: request.projectID) - logger.info("[MobileSync] run profile mutation applied project=\(request.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: true, - errorMessage: nil, - task: nil, - toHex: fromHex - ) - } - - private func handleMobileRunProfileRun( - _ request: RunProfileRunRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling run profile run project=\(request.projectID.uuidString, privacy: .public) profile=\(request.profileID.uuidString, privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - guard let project = projects.first(where: { $0.id == request.projectID }) else { - logger.error("[MobileSync] run profile run rejected unknown project=\(request.projectID.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Project not found on desktop.", - task: nil, - toHex: fromHex - ) - return - } - - await ensureRunProfilesLoaded(for: request.projectID) - guard let profile = runProfiles(for: request.projectID).first(where: { $0.id == request.profileID }) else { - logger.error("[MobileSync] run profile run rejected missing profile=\(request.profileID.uuidString, privacy: .public) project=\(request.projectID.uuidString, privacy: .public) knownProfiles=\(self.runProfiles(for: request.projectID).count, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: false, - errorMessage: "Run profile not found on desktop.", - task: nil, - toHex: fromHex - ) - return - } - - let task = runService.start(profile: profile, project: project) - logger.info("[MobileSync] run profile started task=\(task.id.uuidString, privacy: .public) profile=\(profile.name, privacy: .public) project=\(project.id.uuidString, privacy: .public)") - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID, - ok: true, - errorMessage: nil, - task: mobileRunTaskSnapshot(task), - toHex: fromHex - ) - } - - private func handleMobileRunProfileStop( - _ request: RunProfileStopRequestPayload, - fromHex: String - ) async { - logger.info("[MobileSync] handling run profile stop task=\(request.taskID?.uuidString ?? "", privacy: .public) project=\(request.projectID?.uuidString ?? "", privacy: .public) profile=\(request.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(fromHex.prefix(12)), privacy: .public)") - let stoppedTask: RunTask? - if let taskID = request.taskID { - stoppedTask = runService.task(id: taskID) - runService.stop(taskId: taskID) - } else if let projectID = request.projectID, let profileID = request.profileID { - stoppedTask = runService.activeTasks.first { - $0.project.id == projectID && $0.profile.id == profileID - } - if let stoppedTask { - runService.stop(taskId: stoppedTask.id) - } - } else { - stoppedTask = nil - } - - await replyRunProfileResult( - requestID: request.clientRequestID, - projectID: request.projectID ?? stoppedTask?.project.id ?? UUID(), - ok: stoppedTask != nil, - errorMessage: stoppedTask == nil ? "No matching running task was found." : nil, - task: stoppedTask.map(mobileRunTaskSnapshot), - toHex: fromHex - ) - } - - private func replyRunProfileResult( - requestID: UUID, - projectID: UUID, - ok: Bool, - errorMessage: String?, - task: MobileRunTaskSnapshot?, - toHex hex: String - ) async { - await ensureRunProfilesLoaded(for: projectID) - logger.info("[MobileSync] replying run profile result ok=\(ok, privacy: .public) project=\(projectID.uuidString, privacy: .public) profiles=\(self.runProfiles(for: projectID).count, privacy: .public) task=\(task?.taskId.uuidString ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public) error=\(errorMessage ?? "", privacy: .public)") - let result = RunProfileResultPayload( - clientRequestID: requestID, - projectID: projectID, - ok: ok, - errorMessage: errorMessage, - profiles: runProfiles(for: projectID), - task: task - ) - await MobileSyncService.shared.send(.runProfileResult(result), toHex: hex) - if ok { scheduleMobileSnapshotBroadcast() } - } - - // MARK: - Mobile: Skills / ACP / MCP remote management - - /// Error for malformed remote skill/ACP/MCP requests; its description is - /// surfaced verbatim to the mobile client. - private enum MobileRemoteConfigError: LocalizedError { - case invalidRequest(String) - - var errorDescription: String? { - switch self { - case .invalidRequest(let detail): return detail - } - } - } - /// The marketplace catalog flattened into wire DTOs with current install - /// state. `forceRefresh` bypasses the 5-minute marketplace cache. - private func mobileSkillPlugins(forceRefresh: Bool = false) async -> [MobileSkillPlugin] { - let catalog = await marketplace.fetchCatalog(forceRefresh: forceRefresh) - let installed = await marketplace.installedPluginNames() - return catalog.map { plugin in - MobileSkillPlugin( - id: plugin.id, - name: plugin.name, - summary: plugin.description, - author: plugin.author, - category: plugin.category, - categoryLabel: plugin.categoryLabel, - marketplace: plugin.marketplace, - marketplaceLabel: plugin.marketplaceLabel, - homepage: plugin.homepage, - isInstalled: installed.contains(plugin.name) - ) - } - } + // MARK: - Relocated Stored Properties - private func mobileSkillSources() async -> [MobileSkillSource] { - await marketplace.customSources().map { source in - MobileSkillSource(id: source.id, displayName: source.displayName) - } - } + /// snapshot and every subsequent `load_more_messages` page reuse one parse + /// instead of re-reading the whole jsonl each time — without holding many + /// threads in memory. Live (streaming) sessions bypass this entirely. + var mobileFullMessageCache: (sessionID: String, messages: [ChatMessage])? - private func handleMobileSkillCatalogRequest(_ request: SkillCatalogRequestPayload, fromHex: String) async { - let plugins = await mobileSkillPlugins(forceRefresh: request.forceRefresh) - let sources = await mobileSkillSources() - let result = SkillCatalogResultPayload( - clientRequestID: request.clientRequestID, - ok: true, - errorMessage: nil, - plugins: plugins, - sources: sources - ) - await MobileSyncService.shared.send(.skillCatalogResult(result), toHex: fromHex) - } - - private func handleMobileSkillMutationRequest(_ request: SkillMutationRequestPayload, fromHex: String) async { - let catalog = await marketplace.fetchCatalog() - guard let plugin = catalog.first(where: { $0.id == request.pluginID }) else { - let result = SkillMutationResultPayload( - clientRequestID: request.clientRequestID, - operation: request.operation, - pluginID: request.pluginID, - ok: false, - errorMessage: "Skill not found in the marketplace catalog.", - plugins: await mobileSkillPlugins(), - sources: await mobileSkillSources() - ) - await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) - return - } - - var ok = true - var errorMessage: String? - do { - switch request.operation { - case .install: - try await marketplace.installPlugin(plugin) - marketplaceInstalledNames.insert(plugin.name) - marketplacePluginStates[plugin.id] = .installed - case .uninstall: - try await marketplace.uninstallPlugin(plugin) - marketplaceInstalledNames.remove(plugin.name) - marketplacePluginStates[plugin.id] = .notInstalled - } - } catch { - ok = false - errorMessage = error.localizedDescription - logger.error("[MobileSync] skill mutation failed plugin=\(plugin.name, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - - if ok { - let verb = request.operation == .install ? "installed" : "removed" - await NotificationService.shared.postRemoteConfigChanged( - title: "Skill \(verb) remotely", - body: plugin.name - ) - } - - let result = SkillMutationResultPayload( - clientRequestID: request.clientRequestID, - operation: request.operation, - pluginID: request.pluginID, - ok: ok, - errorMessage: errorMessage, - plugins: await mobileSkillPlugins(), - sources: await mobileSkillSources() - ) - await MobileSyncService.shared.send(.skillMutationResult(result), toHex: fromHex) - } - - private func handleMobileSkillSourceMutationRequest(_ request: SkillSourceMutationRequestPayload, fromHex: String) async { - var ok = true - var errorMessage: String? - var sourceID = request.sourceID - var bannerTitle: String? - var bannerBody: String? - - do { - switch request.operation { - case .add: - guard let gitURL = request.gitURL, - !gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw MobileRemoteConfigError.invalidRequest("Missing Git repository URL.") - } - let source = try await marketplace.addCustomGitSource(url: gitURL, ref: request.ref) - sourceID = source.id - marketplaceCustomSources = await marketplace.customSources() - bannerTitle = "Skill Git source added remotely" - bannerBody = source.displayName - case .remove: - let currentSources = await marketplace.customSources() - guard let sourceID = request.sourceID, - let source = currentSources.first(where: { $0.id == sourceID }) else { - throw MobileRemoteConfigError.invalidRequest("Skill Git source not found.") - } - try await marketplace.removeCustomGitSource(source) - marketplaceCustomSources = await marketplace.customSources() - bannerTitle = "Skill Git source removed remotely" - bannerBody = source.displayName - } - } catch { - ok = false - errorMessage = error.localizedDescription - logger.error("[MobileSync] skill source mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - - let plugins = await mobileSkillPlugins(forceRefresh: true) - let sources = await mobileSkillSources() - marketplaceCatalog = await marketplace.fetchCatalog() - marketplaceInstalledNames = await marketplace.installedPluginNames() - - if ok, let bannerTitle, let bannerBody { - await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) - } - - let result = SkillSourceMutationResultPayload( - clientRequestID: request.clientRequestID, - operation: request.operation, - sourceID: sourceID, - ok: ok, - errorMessage: errorMessage, - plugins: plugins, - sources: sources - ) - await MobileSyncService.shared.send(.skillSourceMutationResult(result), toHex: fromHex) - } - - private func mobileACPRegistryAgents() -> [MobileACPRegistryAgent] { - let installedRegistryIDs = Set(acpClients.compactMap(\.registryId)) - return (acpRegistry?.agents ?? []).map { agent in - MobileACPRegistryAgent( - id: agent.id, - name: agent.name, - version: agent.version, - summary: agent.description, - authors: agent.authors ?? [], - license: agent.license, - website: agent.website, - iconURL: agent.icon, - isInstalled: installedRegistryIDs.contains(agent.id), - hasBinary: agent.distribution.binary?[ACPPlatform.current] != nil, - hasNpx: agent.distribution.npx != nil, - hasUvx: agent.distribution.uvx != nil - ) - } - } - - private func mobileACPClients() -> [MobileACPClient] { - acpClients.map { spec in - MobileACPClient( - id: spec.id, - registryId: spec.registryId, - displayName: spec.displayName, - enabled: spec.enabled, - launchKind: spec.launch.displayKind, - modelCount: spec.models.count, - iconURL: spec.iconURL - ) - } - } - - private func handleMobileACPRegistryRequest(_ request: ACPRegistryRequestPayload, fromHex: String) async { - await refreshACPRegistry(forceRefresh: request.forceRefresh) - let ok = acpRegistry != nil - let result = ACPRegistryResultPayload( - clientRequestID: request.clientRequestID, - ok: ok, - errorMessage: ok ? nil : "Could not load the ACP agent registry.", - registryAgents: mobileACPRegistryAgents(), - installedClients: mobileACPClients() - ) - await MobileSyncService.shared.send(.acpRegistryResult(result), toHex: fromHex) - } - - private func handleMobileACPMutationRequest(_ request: ACPMutationRequestPayload, fromHex: String) async { - var ok = true - var errorMessage: String? - var bannerTitle: String? - var bannerBody: String? - do { - switch request.operation { - case .install: - guard let agentID = request.registryAgentID else { - throw MobileRemoteConfigError.invalidRequest("Missing registry agent id.") - } - if acpRegistry == nil { await refreshACPRegistry() } - guard let agent = acpRegistry?.agents.first(where: { $0.id == agentID }) else { - throw MobileRemoteConfigError.invalidRequest("Agent not found in the registry.") - } - let spec = try await installACPClient(from: agent) - addACPClient(spec) - bannerTitle = "ACP agent installed remotely" - bannerBody = agent.name - case .uninstall: - guard let clientID = request.clientID, - let client = acpClients.first(where: { $0.id == clientID }) - else { - throw MobileRemoteConfigError.invalidRequest("Installed client not found.") - } - removeACPClient(id: clientID) - bannerTitle = "ACP agent removed remotely" - bannerBody = client.displayName - case .setEnabled: - guard let clientID = request.clientID, - let enabled = request.enabled, - var client = acpClients.first(where: { $0.id == clientID }) - else { - throw MobileRemoteConfigError.invalidRequest("Installed client not found.") - } - client.enabled = enabled - updateACPClient(client) - bannerTitle = "ACP agent \(enabled ? "enabled" : "disabled") remotely" - bannerBody = client.displayName - } - } catch { - ok = false - errorMessage = error.localizedDescription - logger.error("[MobileSync] acp mutation failed operation=\(request.operation.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - - if ok, let bannerTitle, let bannerBody { - await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: bannerBody) - } - - let result = ACPMutationResultPayload( - clientRequestID: request.clientRequestID, - operation: request.operation, - ok: ok, - errorMessage: errorMessage, - registryAgents: mobileACPRegistryAgents(), - installedClients: mobileACPClients() - ) - await MobileSyncService.shared.send(.acpMutationResult(result), toHex: fromHex) - } - - private func mobileMCPServer(_ record: MCPServerRecord) -> MobileMCPServer { - let env = record.env - .sorted { $0.key < $1.key } - .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } - let headers = record.headers - .sorted { $0.key < $1.key } - .map { MobileMCPKeyValue(key: $0.key, value: $0.value) } - let endpoint: String - if record.transport == .stdio { - endpoint = ([record.command ?? ""] + record.args) - .filter { !$0.isEmpty } - .joined(separator: " ") - } else { - endpoint = record.url ?? "" - } - return MobileMCPServer( - name: record.name, - transport: record.transport.rawValue, - url: record.url, - command: record.command, - args: record.args, - env: env, - headers: headers, - isGloballyEnabled: record.isGloballyEnabled, - endpoint: endpoint - ) - } - - private func mobileMCPServers() async throws -> [MobileMCPServer] { - try await mcp.globalRecords().map { mobileMCPServer($0) } - } - - private func mcpServerSpec(from server: MobileMCPServer) -> MCPServerSpec { - MCPServerSpec( - name: server.name, - transport: MCPTransport(rawValue: server.transport) ?? .stdio, - url: server.url ?? "", - headers: server.headers.map { MCPKeyValue(key: $0.key, value: $0.value) }, - command: server.command ?? "", - args: server.args, - env: server.env.map { MCPKeyValue(key: $0.key, value: $0.value) } - ) - } - - private func handleMobileMCPConfigRequest(_ request: MCPConfigRequestPayload, fromHex: String) async { - do { - let servers = try await mobileMCPServers() - let result = MCPConfigResultPayload( - clientRequestID: request.clientRequestID, - ok: true, - errorMessage: nil, - servers: servers - ) - await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) - } catch { - let result = MCPConfigResultPayload( - clientRequestID: request.clientRequestID, - ok: false, - errorMessage: error.localizedDescription, - servers: [] - ) - await MobileSyncService.shared.send(.mcpConfigResult(result), toHex: fromHex) - } - } - - private func handleMobileMCPMutationRequest(_ request: MCPMutationRequestPayload, fromHex: String) async { - var ok = true - var errorMessage: String? - var bannerTitle: String? - do { - switch request.operation { - case .add: - guard let server = request.server else { - throw MobileRemoteConfigError.invalidRequest("Missing server definition.") - } - try await mcp.add(spec: mcpServerSpec(from: server), scope: .user, projectPath: nil) - bannerTitle = "MCP server saved remotely" - case .remove: - try await mcp.remove(name: request.serverName, scope: .user) - bannerTitle = "MCP server removed remotely" - case .setEnabled: - guard let enabled = request.enabled else { - throw MobileRemoteConfigError.invalidRequest("Missing enabled flag.") - } - try await mcp.setGlobalEnabled(name: request.serverName, enabled: enabled) - bannerTitle = "MCP server \(enabled ? "enabled" : "disabled") remotely" - } - await refreshMCPServers() - } catch { - ok = false - errorMessage = error.localizedDescription - logger.error("[MobileSync] mcp mutation failed server=\(request.serverName, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - - if ok, let bannerTitle { - await NotificationService.shared.postRemoteConfigChanged(title: bannerTitle, body: request.serverName) - } - - var servers: [MobileMCPServer] = [] - do { - servers = try await mobileMCPServers() - } catch { - logger.error("[MobileSync] failed reading mcp servers for reply: \(error.localizedDescription, privacy: .public)") - } - let result = MCPMutationResultPayload( - clientRequestID: request.clientRequestID, - operation: request.operation, - serverName: request.serverName, - ok: ok, - errorMessage: errorMessage, - servers: servers - ) - await MobileSyncService.shared.send(.mcpMutationResult(result), toHex: fromHex) - } - - private func mobileFolderTreeRoot(for request: FolderTreeRequestPayload) throws -> RemoteFolderNode { - let depth = max(0, min(request.depth, 2)) - guard let path = request.path?.trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty - else { - return RemoteFolderNode( - name: Host.current().localizedName ?? "Mac", - path: "", - isSelectable: false, - children: mobileFolderPickerRoots(depth: 1, includeHidden: request.includeHidden) - ) - } - - return try mobileFolderNode( - for: URL(fileURLWithPath: path).standardizedFileURL, - depth: depth, - includeHidden: request.includeHidden - ) - } - - private func mobileFolderPickerRoots(depth: Int, includeHidden: Bool) -> [RemoteFolderNode] { - let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL - var candidates = [ - home, - home.appendingPathComponent("Desktop", isDirectory: true), - home.appendingPathComponent("Documents", isDirectory: true), - home.appendingPathComponent("Downloads", isDirectory: true), - home.appendingPathComponent("Developer", isDirectory: true) - ] - - let projectParents = projects - .map { URL(fileURLWithPath: $0.path).deletingLastPathComponent().standardizedFileURL } - candidates.append(contentsOf: projectParents) - - var seen: Set = [] - return candidates.compactMap { url in - let path = url.path - guard seen.insert(path).inserted else { return nil } - return try? mobileFolderNode(for: url, depth: depth, includeHidden: includeHidden) - } - } - - private func mobileFolderNode( - for url: URL, - depth: Int, - includeHidden: Bool - ) throws -> RemoteFolderNode { - let fm = FileManager.default - var isDirectory: ObjCBool = false - guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory), - isDirectory.boolValue - else { - throw MobileFolderPickerError.notFolder - } - - let name = url == fm.homeDirectoryForCurrentUser.standardizedFileURL - ? "Home" - : url.lastPathComponent - guard depth > 0 else { - return RemoteFolderNode(name: name, path: url.path, children: []) - } - - var options: FileManager.DirectoryEnumerationOptions = [] - if !includeHidden { options.insert(.skipsHiddenFiles) } - let contents = (try? fm.contentsOfDirectory( - at: url, - includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey], - options: options - )) ?? [] - - let children = contents - .filter { child in - guard let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isHiddenKey]), - values.isDirectory == true - else { return false } - if !includeHidden && (values.isHidden == true || child.lastPathComponent.hasPrefix(".")) { - return false - } - return !Self.mobileFolderIgnoredNames.contains(child.lastPathComponent) - } - .sorted { lhs, rhs in - lhs.lastPathComponent.localizedStandardCompare(rhs.lastPathComponent) == .orderedAscending - } - .prefix(Self.mobileFolderMaxChildren) - .compactMap { child in - try? mobileFolderNode( - for: child.standardizedFileURL, - depth: depth - 1, - includeHidden: includeHidden - ) - } - - return RemoteFolderNode(name: name, path: url.path, children: Array(children)) - } - - private func mobileFolderErrorMessage(_ error: Error) -> String { - if let folderError = error as? MobileFolderPickerError { - return folderError.localizedDescription - } - return error.localizedDescription - } - - private static let mobileFolderMaxChildren = 250 - private static let mobileFolderIgnoredNames: Set = [ - ".git", ".build", ".swiftpm", "DerivedData", - "node_modules", ".DS_Store", "Pods", "xcuserdata" - ] - - private enum MobileFolderPickerError: LocalizedError { - case notFolder - - var errorDescription: String? { - switch self { - case .notFolder: - return "Folder does not exist on the desktop." - } - } - } - - private func handleMobileCancelStream(_ cancel: CancelStreamPayload) async { - // The mobile may hold a session id the CLI has since advanced - // (pending-→real swap, or a compaction boundary). Follow the redirect - // chain so the cancel lands on the live, streaming thread — otherwise - // `sessionStates[sessionID]` is nil and the stop button is a no-op. - let sessionID = resolveCurrentSessionId(cancel.sessionID) - guard sessionStates[sessionID]?.isStreaming == true else { - logger.info("[MobileSync] cancel ignored — thread=\(sessionID, privacy: .public) (from \(cancel.sessionID, privacy: .public)) is not streaming") - return - } - let window = WindowState() - window.currentSessionId = sessionID - // Resolve the project so cancelStreaming can persist the partial - // messages accumulated up to the cancellation point. - if let summary = allSessionSummaries.first(where: { $0.id == sessionID }) { - window.selectedProject = projects.first(where: { $0.id == summary.projectId }) - } - await cancelStreaming(in: window) - // cancelStreaming intentionally skips finalizeStreamSession, so no - // status update is emitted — broadcast it so the mobile flips its - // stop button back to send. - broadcastMobileSessionStatus(sessionID: sessionID) - } - - private func handleMobileUserMessage(_ message: UserMessagePayload, fromHex: String) async { - let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - - let sessionID = resolveCurrentSessionId(message.sessionID) - guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }), - let project = projects.first(where: { $0.id == summary.projectId }) - else { - logger.error("[MobileSync] user message for unknown thread=\(message.sessionID, privacy: .public)") - await sendMobileSnapshot(toHex: fromHex, activeSessionID: nil) - return - } - - await hydrateMobileSessionIfNeeded(summary: summary, project: project) - updateMobilePlaceholderTitleIfNeeded(sessionID: sessionID, firstUserMessage: text) - - // If a turn is already streaming, the mobile message goes into the - // shared (threadStore-backed) queue so it flushes once the current turn - // ends — same behavior as the macOS InputBarView's enqueue path. - if sessionStates[sessionID]?.isStreaming == true { - let queued = QueuedMessage(text: text, attachments: []) - threadStore.appendQueued(sessionKey: sessionID, message: queued) - appendToWindowQueueMirrors(sessionID: sessionID, message: queued) - broadcastMobileSessionStatus(sessionID: sessionID) - return - } - - let window = WindowState() - window.selectedProject = project - window.currentSessionId = sessionID - if sessionID.hasPrefix("pending-mobile-") { - window.insertPendingPlaceholder(sessionID) - } - _ = await sendPrompt(text, displayText: text, in: window) - await sendMobileSnapshot(toHex: fromHex, activeSessionID: window.currentSessionId) - } - - private func handleMobileRemoveQueuedMessage(_ payload: RemoveQueuedMessagePayload) { - threadStore.removeQueued(id: payload.queuedMessageID) - evictFromWindowQueueMirrors(sessionID: payload.sessionID, queuedID: payload.queuedMessageID) - broadcastMobileSessionStatus(sessionID: payload.sessionID) - } - - /// Flushes the next queued message from threadStore as a new user turn. - /// AppState is the single auto-flush authority — both macOS-side and - /// mobile-side queued items are flushed here, so the two flows never - /// race on duplicate sends. Triggered at the tail of `finalizeStreamSession`. - /// - /// Any macOS window currently viewing the session keeps a mirror copy of - /// the queue in `window.messageQueue`; clear the popped entry from those - /// mirrors so the UI doesn't show a stale queued row. - private func flushNextQueuedMessageIfNeeded(sessionID: String) async { - let queue = threadStore.loadQueue(sessionKey: sessionID) - guard let next = queue.first else { return } - guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }), - let project = projects.first(where: { $0.id == summary.projectId }) - else { return } - - threadStore.removeQueued(id: next.id) - evictFromWindowQueueMirrors(sessionID: sessionID, queuedID: next.id) - broadcastMobileSessionStatus(sessionID: sessionID) - - let window = WindowState() - window.selectedProject = project - window.currentSessionId = sessionID - _ = await sendPrompt( - next.text, - displayText: next.text, - attachments: next.attachments, - in: window - ) - } - - /// Mirror of `enqueueMessage` for queue items appended by AppState itself - /// (e.g. when a mobile-sent message arrives while the session is streaming). - /// Every desktop window currently viewing the session keeps an in-memory - /// copy in `messageQueue` and `draftQueues[sessionID]`; push the new entry - /// into those mirrors so the chat UI shows the queued row immediately, - /// matching the macOS enqueue path. - private func appendToWindowQueueMirrors(sessionID: String, message: QueuedMessage) { - for window in registeredWindows() where window.currentSessionId == sessionID { - if !window.messageQueue.contains(where: { $0.id == message.id }) { - window.messageQueue.append(message) - } - var mirror = window.draftQueues[sessionID] ?? [] - if !mirror.contains(where: { $0.id == message.id }) { - mirror.append(message) - } - window.draftQueues[sessionID] = mirror - } - } - - /// macOS windows hold an in-memory mirror of the queue in `messageQueue` and - /// in `draftQueues[sessionID]`. When AppState pops an entry from threadStore - /// (auto-flush or remote remove), drop it from every registered window so - /// the UI doesn't show a phantom queued row. - private func evictFromWindowQueueMirrors(sessionID: String, queuedID: UUID) { - for window in registeredWindows() where window.currentSessionId == sessionID { - window.messageQueue.removeAll { $0.id == queuedID } - if var mirror = window.draftQueues[sessionID] { - mirror.removeAll { $0.id == queuedID } - if mirror.isEmpty { - window.draftQueues.removeValue(forKey: sessionID) - } else { - window.draftQueues[sessionID] = mirror - } - } - } - } - - private func createMobilePlaceholderSession(project: Project, requestID: UUID) -> String { - let sessionID = "pending-mobile-\(requestID.uuidString)" - if allSessionSummaries.contains(where: { $0.id == sessionID }) { - return sessionID - } - - let selection = defaultModelSelection(for: project) - let session = ChatSession( - id: sessionID, - projectId: project.id, - title: ChatSession.defaultTitle, - agentProvider: selection.provider, - model: selection.model, - origin: selection.provider.defaultSessionOrigin - ) - allSessionSummaries.insert(session.summary, at: 0) - threadStore.upsert(session.summary) - updateState(sessionID) { state in - state.agentProvider = selection.provider - state.model = selection.model - } - return sessionID - } - - private func hydrateMobileSessionIfNeeded(summary: ChatSession.Summary, project: Project) async { - if sessionStates[summary.id] == nil, - let full = await persistence.loadFullSession(summary: summary, cwd: project.path) { - updateState(summary.id) { state in - state.messages = full.messages - } - } - - updateState(summary.id) { state in - if state.agentProvider == nil { state.agentProvider = summary.agentProvider } - if state.model == nil { state.model = summary.model } - if state.effort == nil { state.effort = summary.effort } - if state.permissionMode == nil { state.permissionMode = summary.permissionMode } - if state.worktreePath == nil { state.worktreePath = summary.worktreePath } - if state.worktreeBranch == nil { state.worktreeBranch = summary.worktreeBranch } - } - } - - private func updateMobilePlaceholderTitleIfNeeded(sessionID: String, firstUserMessage: String) { - guard let index = allSessionSummaries.firstIndex(where: { $0.id == sessionID }) else { return } - let current = allSessionSummaries[index] - guard current.title == ChatSession.defaultTitle || current.title.isEmpty else { return } - allSessionSummaries[index].title = ChatSession.placeholderTitle(from: firstUserMessage) - allSessionSummaries[index].updatedAt = Date() - threadStore.upsert(allSessionSummaries[index]) - } - - private func scheduleMobileSnapshotBroadcast() { - mobileSnapshotBroadcastTask?.cancel() - mobileSnapshotBroadcastTask = Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 250_000_000) - guard !Task.isCancelled else { return } - await self?.broadcastMobileSnapshots() - } - } - - private func broadcastMobileSnapshots() async { - for device in MobileSyncService.shared.pairedDevices { - await sendMobileSnapshot(toHex: device.pubkeyHex, activeSessionID: nil) - } - } - - private func handleMobileSearchRequest(_ request: SearchRequestPayload, fromHex hex: String) async { - let trimmed = request.query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - let empty = SearchResultsPayload( - clientRequestID: request.clientRequestID, - query: request.query, - projectIDs: [], - threadHits: [] - ) - await MobileSyncService.shared.send(.searchResults(empty), toHex: hex) - return - } - - let knownProjectIDs = Set(projects.map(\.id)) - let summaries = allSessionSummaries.filter { knownProjectIDs.contains($0.projectId) } - let summaryByID = Dictionary(uniqueKeysWithValues: summaries.map { ($0.id, $0) }) - - let semantic = await searchService.search(trimmed, limit: max(request.limit, 1)) - - var threadHitByID: [String: SearchHit] = [:] - for group in semantic { - for hit in group.hits { - guard let summary = summaryByID[hit.threadId] else { continue } - let title = ChatSession.stripAttachmentMarkers(from: summary.title) - threadHitByID[hit.threadId] = SearchHit( - sessionID: hit.threadId, - projectID: hit.projectId, - title: title.isEmpty ? ChatSession.defaultTitle : title, - snippet: hit.snippet, - updatedAt: summary.updatedAt, - score: hit.score - ) - } - } - - let lowered = trimmed.lowercased() - for summary in summaries where threadHitByID[summary.id] == nil { - let cleaned = ChatSession.stripAttachmentMarkers(from: summary.title) - guard cleaned.lowercased().contains(lowered) else { continue } - let title = cleaned.isEmpty ? ChatSession.defaultTitle : cleaned - threadHitByID[summary.id] = SearchHit( - sessionID: summary.id, - projectID: summary.projectId, - title: title, - snippet: title, - updatedAt: summary.updatedAt, - score: 0 - ) - } - - let threadHits = threadHitByID.values - .sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - return lhs.updatedAt > rhs.updatedAt - } - .prefix(max(request.limit, 1)) - - let projectIDs = projects - .filter { project in - project.name.lowercased().contains(lowered) - || project.path.lowercased().contains(lowered) - } - .map(\.id) - - let payload = SearchResultsPayload( - clientRequestID: request.clientRequestID, - query: request.query, - projectIDs: projectIDs, - threadHits: Array(threadHits) - ) - await MobileSyncService.shared.send(.searchResults(payload), toHex: hex) - } - - private func sendMobileSnapshot(toHex hex: String, activeSessionID: String?) async { - // Populate the OpenAI summarization model list so mobile's model - // picker has options. Fetched at most once — a prior failure (no API - // key, bad endpoint) is recorded in `openAISummarizationModelsError` - // and not retried on every snapshot. - if summarizationProvider == .openAI, - openAISummarizationModels.isEmpty, - openAISummarizationModelsError == nil, - !isLoadingOpenAISummarizationModels { - await refreshOpenAISummarizationModels() - } - let active = await mobileActiveSessionPayload(for: activeSessionID) - // Viewing a thread on any paired device counts as reading it: drop the - // "finished, unread" flag so the green indicator clears on desktop and - // mobile alike. Done before the snapshot is built so the payload below - // already reflects the cleared state. - if let activeID = active.id, sessionStates[activeID]?.hasUncheckedCompletion == true { - updateState(activeID) { $0.hasUncheckedCompletion = false } - } - let branches = await mobileProjectBranches() - // Keep mobile usage reasonably fresh. Non-forced — RateLimitService's - // 5-minute cache turns this into a no-op when usage was fetched - // recently, so frequent snapshots don't translate into API calls. A - // genuine refresh updates `latestRateLimitUsage`, which - // `observeMobileSnapshotInputs` tracks, re-broadcasting the snapshot. - Task { [weak self] in - await self?.refreshRateLimitUsage() - await self?.refreshCodexRateLimitUsage() - } - let hostMetrics = await SystemMetricsService.shared.sample() - let runProfiles = await mobileRunProfiles() - let runTasks = mobileRunTaskSnapshots() - let webProxy = await localWebProxy.proxyInfo() - if let webProxy { - logger.info("[WebBrowserSync] snapshot includes web proxy host=\(webProxy.host, privacy: .public) port=\(webProxy.port, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") - } else { - logger.warning("[WebBrowserSync] snapshot has no web proxy info to mobileKey=\(String(hex.prefix(12)), privacy: .public)") - } - let payload = SnapshotPayload( - projects: projects, - sessions: mobileSessionSummaries(), - branchBriefings: mobileBranchBriefings(), - threadSummaries: mobileThreadSummaries(), - settings: mobileSettingsSnapshot(), - activeSessionID: active.id, - activeSessionMessages: active.messages, - activeSessionHasMore: active.hasMore, - projectBranches: branches, - usage: MobileUsageSnapshot( - claudeCode: latestRateLimitUsage, - codex: latestCodexRateLimitUsage - ), - hostMetrics: hostMetrics, - runProfiles: runProfiles, - runTasks: runTasks, - webProxy: webProxy - ) - await MobileSyncService.shared.send(.snapshot(payload), toHex: hex) - // The snapshot doesn't carry the question queue; send it alongside so a - // freshly connected device renders any outstanding question banner. - await MobileSyncService.shared.send( - .questionQueue(QuestionQueuePayload(questions: mobilePendingQuestionPayloads())), - toHex: hex - ) - logger.info( - "[MobileSync] sent snapshot projects=\(self.projects.count, privacy: .public) sessions=\(payload.sessions.count, privacy: .public) runProfileProjects=\(runProfiles.count, privacy: .public) runProfileTotal=\(runProfiles.reduce(0) { $0 + $1.profiles.count }, privacy: .public) runTasks=\(runTasks.count, privacy: .public) active=\(active.id ?? "", privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)" - ) - } - - private func mobileSessionSummaries() -> [RxCodeSync.SessionSummary] { - let knownProjectIds = Set(projects.map(\.id)) - return allSessionSummaries - .filter { knownProjectIds.contains($0.projectId) } - .sorted { lhs, rhs in - if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } - return lhs.updatedAt > rhs.updatedAt - } - .map { - mobileSessionSummary(from: $0) - } - } - - private func mobileRunProfiles() async -> [MobileProjectRunProfiles] { - var result: [MobileProjectRunProfiles] = [] - for project in projects { - await ensureRunProfilesLoaded(for: project.id) - let profiles = runProfiles(for: project.id) - result.append(MobileProjectRunProfiles( - projectId: project.id, - profiles: profiles - )) - } - return result - } - - private func mobileRunTaskSnapshots() -> [MobileRunTaskSnapshot] { - runService.tasks.map(mobileRunTaskSnapshot) - } - - private func mobileRunTaskSnapshot(_ task: RunTask) -> MobileRunTaskSnapshot { - MobileRunTaskSnapshot( - taskId: task.id, - projectId: task.project.id, - profileId: task.profile.id, - profileName: task.profile.name, - status: mobileRunTaskStatus(task.status), - statusLabel: task.status.label, - exitCode: task.exitCode, - startedAt: task.startedAt, - resolvedCwd: task.resolvedCwd, - commandPreview: mobileRunTaskCommandPreview(task), - terminalOutputTail: String(task.terminalOutputTail.suffix(8_000)) - ) - } - - private func mobileRunTaskStatus(_ status: RunTaskStatus) -> MobileRunTaskSnapshot.Status { - switch status { - case .running: return .running - case .succeeded: return .succeeded - case .failed: return .failed - case .signaled: return .signaled - case .stopped: return .stopped - } - } - - private func mobileRunTaskCommandPreview(_ task: RunTask) -> String { - let lines = task.wrapperScript - .split(separator: "\n", omittingEmptySubsequences: false) - .map(String.init) - if let mainIndex = lines.firstIndex(of: "# --- main ---") { - return lines.dropFirst(mainIndex + 1) - .prefix(8) - .joined(separator: "\n") - } - return lines.suffix(8).joined(separator: "\n") - } - - private func broadcastMobileRunTasks() { - guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } - let currentSnapshots = runService.tasks.prefix(5).map(mobileRunTaskSnapshot) - let currentById = Dictionary(uniqueKeysWithValues: currentSnapshots.map { ($0.taskId, $0) }) - let removedIds = Set(lastBroadcastRunTaskSnapshots.keys).subtracting(currentById.keys) - if !removedIds.isEmpty { - scheduleMobileSnapshotBroadcast() - } - for snapshot in currentSnapshots where lastBroadcastRunTaskSnapshots[snapshot.taskId] != snapshot { - MobileSyncService.shared.broadcastRunTaskUpdate(snapshot) - } - lastBroadcastRunTaskSnapshots = currentById - } - - private func mobileSessionSummary(for sessionID: String) -> RxCodeSync.SessionSummary? { - guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { - return nil - } - return mobileSessionSummary(from: summary) - } - - private func mobileSessionSummary(from summary: ChatSession.Summary) -> RxCodeSync.SessionSummary { - let todos = mobileTodoItems(forSessionId: summary.id) - let progress = mobileProgressSnapshot(forSessionId: summary.id) - let queued = threadStore.loadQueue(sessionKey: summary.id).map { - QueuedUserMessage(id: $0.id, text: $0.text) - } - return RxCodeSync.SessionSummary( - id: summary.id, - projectId: summary.projectId, - title: summary.title, - updatedAt: summary.updatedAt, - isPinned: summary.isPinned, - isArchived: summary.isArchived, - isStreaming: sessionStates[summary.id]?.isStreaming ?? false, - attention: mobileAttentionKind(forSessionId: summary.id), - progress: progress, - todos: todos, - queuedMessages: queued, - hasUncheckedCompletion: sessionStates[summary.id]?.hasUncheckedCompletion ?? false - ) - } - - private func mobileProgressSnapshot(forSessionId id: String) -> SessionProgressSnapshot? { - if let todos = mobileTodoItems(forSessionId: id) { - return SessionProgressSnapshot( - done: todos.filter { $0.status == .completed }.count, - total: todos.count, - inProgress: todos.contains { $0.status == .inProgress } - ) - } - - return nil - } - - private func mobileTodoItems(forSessionId id: String) -> [TodoItem]? { - if let messages = sessionStates[id]?.messages, - let todos = TodoExtractor.latest(in: messages) - { - return todos - } - - guard let snapshot = threadStore.fetchTodoSnapshot(sessionId: id), snapshot.total > 0 else { - return nil - } - - return snapshot.items - } - - private func mobileAttentionKind(forSessionId id: String) -> SessionAttentionKind? { - let requests = mobilePendingRequests.values.filter { $0.sessionId == id } - if requests.contains(where: { $0.toolName == "AskUserQuestion" }) { - return .question - } - if !requests.isEmpty { - return .permission - } - return nil - } - - /// Diff two message arrays for a session and emit `messageAppended` / - /// `messageUpdated` payloads for each change. Called from `updateState` - /// and `flushPendingUpdates` so every visible mutation reaches mobile - /// without per-call site instrumentation. - private func broadcastMobileMessageDiff( - sessionKey: String, - prev: [ChatMessage], - next: [ChatMessage], - isStreaming: Bool - ) { - guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } - if prev.count == next.count, prev == next { return } - // Diff against the raw messages (so a CLI result overwrite still counts - // as a change), but bake the user-decision summary into the broadcast - // copy so the mobile plan banner clears once a plan is resolved. - let summaries = sessionStates[sessionKey]?.planDecisionSummaries ?? [:] - let prevById = Dictionary(uniqueKeysWithValues: prev.map { ($0.id, $0) }) - for message in next { - if let old = prevById[message.id] { - guard old != message else { continue } - MobileSyncService.shared.broadcastSessionUpdate( - sessionID: sessionKey, - kind: .messageUpdated, - message: messageWithPlanDecisions(message, summaries: summaries), - isStreaming: isStreaming - ) - } else { - MobileSyncService.shared.broadcastSessionUpdate( - sessionID: sessionKey, - kind: .messageAppended, - message: messageWithPlanDecisions(message, summaries: summaries), - isStreaming: isStreaming - ) - } - } - } - - private func broadcastMobileSessionStatus(sessionID: String, kind: SessionUpdatePayload.Kind = .statusChanged) { - guard let summary = mobileSessionSummary(for: sessionID) else { return } - MobileSyncService.shared.broadcastSessionUpdate( - sessionID: sessionID, - kind: kind, - message: nil, - isStreaming: summary.isStreaming, - summary: summary - ) - } - - /// Wire representation of every `AskUserQuestion` call currently awaiting an - /// answer, used to mirror the desktop's question queue to mobile. - private func mobilePendingQuestionPayloads() -> [PendingQuestionPayload] { - mobilePendingRequests.values - .filter { $0.toolName == "AskUserQuestion" } - .compactMap { request in - guard let sessionId = request.sessionId, - let data = try? JSONEncoder().encode(request.toolInput), - let json = String(data: data, encoding: .utf8) - else { return nil } - return PendingQuestionPayload( - toolUseID: request.id, - sessionID: sessionId, - toolInputJSON: json - ) - } - } - - /// Broadcast the current `AskUserQuestion` queue to paired mobile devices. - /// Called whenever the queue changes (a question is added or resolved) so - /// mobile mirrors it exactly — additions and retractions alike. - private func broadcastMobileQuestionQueue() { - guard !MobileSyncService.shared.pairedDevices.isEmpty else { return } - MobileSyncService.shared.broadcastQuestionQueue(mobilePendingQuestionPayloads()) - } - - private func broadcastMobileSessionRedirect(from previousSessionID: String, to sessionID: String) { - guard previousSessionID != sessionID, - let summary = mobileSessionSummary(for: sessionID) - else { return } - MobileSyncService.shared.broadcastSessionUpdate( - sessionID: sessionID, - kind: .statusChanged, - message: nil, - isStreaming: summary.isStreaming, - summary: summary, - previousSessionID: previousSessionID - ) - scheduleMobileSnapshotBroadcast() - } - - private func mobileBranchBriefings() -> [MobileBranchBriefing] { - let knownProjectIds = Set(projects.map(\.id)) - return threadStore.allBranchBriefingItems() - .filter { knownProjectIds.contains($0.projectId) } - .map { - MobileBranchBriefing( - projectId: $0.projectId, - branch: $0.branch, - briefing: $0.briefing, - updatedAt: $0.updatedAt - ) - } - } - - private func mobileThreadSummaries() -> [MobileThreadSummary] { - let knownProjectIds = Set(projects.map(\.id)) - return threadStore.allThreadSummaryItems() - .filter { knownProjectIds.contains($0.projectId) } - .map { - MobileThreadSummary( - sessionId: $0.sessionId, - projectId: $0.projectId, - branch: $0.branch, - title: $0.title, - summary: $0.summary, - updatedAt: $0.updatedAt - ) - } - } - - private func mobileSettingsSnapshot() -> MobileSettingsSnapshot { - let sections = availableAgentModelSections().map { - AgentModelSection( - id: $0.id, - title: $0.title, - provider: $0.provider, - iconURL: $0.iconURL, - models: $0.models - ) - } - let models = sections.flatMap(\.models) - return MobileSettingsSnapshot( - selectedAgentProvider: selectedAgentProvider, - selectedModel: selectedModel, - selectedACPClientId: selectedACPClientId, - selectedEffort: selectedEffort, - permissionMode: permissionMode, - summarizationProvider: summarizationProvider.rawValue, - summarizationProviderDisplayName: summarizationProvider.displayName, - openAISummarizationEndpoint: openAISummarizationEndpoint, - openAISummarizationModel: openAISummarizationModel, - notificationsEnabled: notificationsEnabled, - focusMode: focusMode, - autoArchiveEnabled: autoArchiveEnabled, - archiveRetentionDays: archiveRetentionDays, - autoPreviewSettings: autoPreviewSettings, - availableEfforts: ["auto"] + Self.availableEfforts, - availableModels: models, - modelSections: sections, - availableSummarizationProviders: SummarizationProvider.availableCases.map { - SummarizationProviderOption(id: $0.rawValue, displayName: $0.displayName) - }, - openAISummarizationModels: openAISummarizationModels - ) - } - - /// Resolve the current git branch and the local branch list for each known - /// project. Runs the per-project git calls concurrently so a large project - /// list doesn't serialize on disk I/O. - private func mobileProjectBranches() async -> [ProjectBranchInfo] { - let inputs = projects.map { (id: $0.id, path: $0.path) } - return await withTaskGroup(of: ProjectBranchInfo?.self) { group in - for input in inputs { - group.addTask { - async let current = GitHelper.currentBranch(at: input.path) - async let list = GitHelper.listLocalBranches(at: input.path) - guard let branch = await current else { return nil } - let branches = await list - return ProjectBranchInfo( - projectId: input.id, - currentBranch: branch, - availableBranches: branches.isEmpty ? nil : branches - ) - } - } - var result: [ProjectBranchInfo] = [] - for await info in group { - if let info { result.append(info) } - } - return result - } - } - - private func applyMobileSettingsUpdate(_ update: MobileSettingsUpdatePayload) { - if let provider = update.selectedAgentProvider { - selectedAgentProvider = provider - } - if let model = update.selectedModel { - selectedModel = model - } - if let clientId = update.selectedACPClientId { - selectedACPClientId = clientId - } - if let effort = update.selectedEffort, effort == "auto" || Self.availableEfforts.contains(effort) { - selectedEffort = effort - } - if let mode = update.permissionMode { - permissionMode = mode - } - if let rawProvider = update.summarizationProvider, - let provider = SummarizationProvider(rawValue: rawProvider), - SummarizationProvider.availableCases.contains(provider) { - summarizationProvider = provider - } - if let model = update.openAISummarizationModel { - openAISummarizationModel = model - } - if let enabled = update.notificationsEnabled { - notificationsEnabled = enabled - } - if let enabled = update.focusMode { - focusMode = enabled - } - if let enabled = update.autoArchiveEnabled { - autoArchiveEnabled = enabled - } - if let days = update.archiveRetentionDays { - archiveRetentionDays = max(1, min(365, days)) - } - if let previews = update.autoPreviewSettings { - autoPreviewSettings = previews - } - } - - /// Number of messages in one mobile history page. Mobile loads the most - /// recent page on subscribe and requests older pages as the user scrolls up. - static let mobileMessagePageSize = 30 - - /// Encoded byte ceiling for the message slice carried in one sync frame. - /// The relay caps a WebSocket frame at 10 MiB and the encrypted envelope - /// inflates the plaintext by roughly a third (base64), so an oversized - /// snapshot would have `task.send` throw and be silently dropped — leaving - /// mobile with only the live stream and no history. Holding the message - /// slice to 3 MiB leaves ample room for the rest of the snapshot (projects, - /// session summaries, briefings) and the envelope overhead; older messages - /// beyond the budget are paged in on demand via `load_more_messages`. - static let mobileMessagePageByteBudget = 3 * 1024 * 1024 - - /// Largest suffix of `messages[.. (page: [ChatMessage], startIndex: Int) { - let clampedEnd = min(max(end, 0), messages.count) - guard clampedEnd > 0, countLimit > 0 else { return ([], clampedEnd) } - let encoder = JSONEncoder() - var startIndex = clampedEnd - var byteCount = 0 - var index = clampedEnd - 1 - while index >= 0 { - let size = (try? encoder.encode(messages[index]))?.count ?? 0 - let takenSoFar = clampedEnd - startIndex - // Always accept the newest message; afterwards stop once either - // budget would be exceeded so the frame stays under the relay cap. - if takenSoFar > 0, - takenSoFar >= countLimit - || byteCount + size > Self.mobileMessagePageByteBudget { - break - } - byteCount += size - startIndex = index - index -= 1 - } - return (Array(messages[startIndex ..< clampedEnd]), startIndex) - } - - /// Single-entry cache of a disk-loaded session's full message list. Mobile - /// pages one thread at a time, so caching just the most recent one lets the - /// snapshot and every subsequent `load_more_messages` page reuse one parse - /// instead of re-reading the whole jsonl each time — without holding many - /// threads in memory. Live (streaming) sessions bypass this entirely. - private var mobileFullMessageCache: (sessionID: String, messages: [ChatMessage])? - - /// Resolve the full, cleaned message list for a session — from live stream - /// state when available, otherwise from disk (cached). `nil` only when the - /// session genuinely can't be located. - private func fullMobileMessages(for resolvedID: String) async -> [ChatMessage]? { - if let state = sessionStates[resolvedID] { - return messagesWithPlanDecisions( - cleanLoadedMessages(state.messages), - summaries: state.planDecisionSummaries - ) - } - // Non-active session: the in-memory sidecar is absent, so pull the - // persisted decisions from the thread store. - let summaries = threadStore.loadPlanDecisions(sessionId: resolvedID) - if let cache = mobileFullMessageCache, cache.sessionID == resolvedID { - return messagesWithPlanDecisions(cache.messages, summaries: summaries) - } - guard let summary = allSessionSummaries.first(where: { $0.id == resolvedID }), - let project = projects.first(where: { $0.id == summary.projectId }), - let full = await persistence.loadFullSession(summary: summary, cwd: project.path) - else { - return nil - } - let cleaned = cleanLoadedMessages(full.messages) - mobileFullMessageCache = (resolvedID, cleaned) - return messagesWithPlanDecisions(cleaned, summaries: summaries) - } - - /// Bake persisted plan decisions into `ExitPlanMode` tool results before - /// messages are sent to mobile. Once a plan resolves, the CLI overwrites the - /// tool result with its own follow-up text ("User has approved your plan…"), - /// which the desktop UI sidesteps with the `planDecisionSummaries` sidecar. - /// Mobile has no such sidecar — it reads `toolCall.result` directly — so the - /// user-decision summary must be written onto the wire result, otherwise the - /// mobile plan banner reappears after a decision. - private func messagesWithPlanDecisions( - _ messages: [ChatMessage], - summaries: [String: String] - ) -> [ChatMessage] { - guard !summaries.isEmpty else { return messages } - return messages.map { messageWithPlanDecisions($0, summaries: summaries) } - } - - private func messageWithPlanDecisions( - _ message: ChatMessage, - summaries: [String: String] - ) -> ChatMessage { - guard !summaries.isEmpty else { return message } - var result = message - for block in message.blocks { - guard let toolCall = block.toolCall, - PlanLogic.isExitPlanMode(toolCall), - let summary = summaries[toolCall.id] else { continue } - // Skip when the result is already the user-decision string. - if toolCall.result.map(PlanDecisionAction.isUserDecisionResult) != true { - result.setToolResult(id: toolCall.id, result: summary, isError: false) - } - } - return result - } - - /// Build the active-session payload for a snapshot: only the most recent - /// page of messages, plus whether older messages remain. Mobile pages the - /// rest in via `load_more_messages` so a snapshot never carries a whole - /// (potentially multi-MB) thread history in one frame. - private func mobileActiveSessionPayload( - for requestedID: String? - ) async -> (id: String?, messages: [ChatMessage]?, hasMore: Bool) { - guard let requestedID else { return (nil, nil, false) } - let resolvedID = resolveCurrentSessionId(requestedID) - guard let all = await fullMobileMessages(for: resolvedID) else { - return (nil, nil, false) - } - let (page, startIndex) = mobileMessagePage( - from: all, - endingAt: all.count, - countLimit: Self.mobileMessagePageSize - ) - return (resolvedID, page, startIndex > 0) - } - - /// Reply to a mobile `load_more_messages` request with the page of messages - /// immediately older than `beforeMessageID`. - private func handleMobileLoadMoreMessages( - _ request: LoadMoreMessagesRequestPayload, - fromHex: String - ) async { - let resolvedID = resolveCurrentSessionId(request.sessionID) - let all = await fullMobileMessages(for: resolvedID) ?? [] - guard let anchorIndex = all.firstIndex(where: { $0.id == request.beforeMessageID }) else { - // The anchor is gone (thread changed, or never loaded). Stop paging. - await MobileSyncService.shared.send( - .moreMessages(MoreMessagesPayload( - clientRequestID: request.clientRequestID, - sessionID: request.sessionID, - messages: [], - hasMore: false - )), - toHex: fromHex - ) - return - } - let limit = max(1, request.limit) - let (page, startIndex) = mobileMessagePage( - from: all, - endingAt: anchorIndex, - countLimit: limit - ) - await MobileSyncService.shared.send( - .moreMessages(MoreMessagesPayload( - clientRequestID: request.clientRequestID, - sessionID: request.sessionID, - messages: page, - hasMore: startIndex > 0 - )), - toHex: fromHex - ) - } - - /// Builds the change overview for the mobile "View Changes" sheet: every - /// file edited in the thread session plus the project's uncommitted git - /// changes. Replies with a `threadChangesResult`. - private func handleMobileThreadChangesRequest( - _ request: ThreadChangesRequestPayload, - fromHex hex: String - ) async { - let resolvedID = resolveCurrentSessionId(request.sessionID) - - // This Turn: every file edited in the thread session (SwiftData history). - let turnEdits = threadStore.fetchFileEdits(sessionId: resolvedID).map { edit -> SyncFileEdit in - let summary = edit.toSummary() - return SyncFileEdit( - path: summary.path, - name: summary.name, - containsWrite: summary.containsWrite, - hunks: summary.hunks.map { - SyncEditHunk(oldString: $0.oldString, newString: $0.newString) - } - ) - } - - func reply(ok: Bool, error: String?, uncommitted: [SyncGitChange]) async { - await MobileSyncService.shared.send( - .threadChangesResult(ThreadChangesResultPayload( - clientRequestID: request.clientRequestID, - sessionID: request.sessionID, - ok: ok, - errorMessage: error, - turnEdits: turnEdits, - uncommitted: uncommitted - )), - toHex: hex - ) - } - - // Uncommitted: the session's project working tree. - let projectPath = allSessionSummaries - .first(where: { $0.id == resolvedID }) - .flatMap { summary in projects.first(where: { $0.id == summary.projectId })?.path } - - guard let projectPath, !projectPath.isEmpty else { - await reply(ok: false, error: "This thread has no associated project folder.", uncommitted: []) - return - } - - guard let gitChanges = await GitHelper.uncommittedChanges(at: projectPath) else { - await reply(ok: false, error: "This project is not a git repository.", uncommitted: []) - return - } - - let uncommitted = gitChanges.map { change -> SyncGitChange in - let kind: SyncGitChangeKind = switch change.kind { - case .staged: .staged - case .unstaged: .unstaged - case .untracked: .untracked - } - return SyncGitChange( - displayPath: change.displayPath, - statusChar: change.statusChar, - kind: kind, - unifiedDiff: change.unifiedDiff, - truncated: change.truncated - ) - } - await reply(ok: true, error: nil, uncommitted: uncommitted) - } - - /// User-triggered full reindex of every thread. Wipes cached embeddings, - /// then re-embeds every thread. Updates `reindexProgress` so the UI can - /// render a counter. - func reindexAllThreads() async { - guard reindexProgress == nil else { return } - reindexProgress = (0, 0) - let searchService = self.searchService - let threadStore = self.threadStore - let persistence = self.persistence - await searchService.reindexAll( - loadAll: { @MainActor in threadStore.loadAllSummaries() }, - loadFull: { @MainActor [weak self] summary -> ChatSession? in - let cwd = self?.projects.first(where: { $0.id == summary.projectId })?.path ?? "" - return await persistence.loadFullSession(summary: summary, cwd: cwd) - }, - progress: { [weak self] done, total in - Task { @MainActor in self?.reindexProgress = (done, total) } - } - ) - reindexProgress = nil - } - - // MARK: - Memory - - func allMemoryItems() async -> [MemoryItem] { - await memoryService.allMemories() - } - - func searchMemoryItems(query: String, projectId: UUID? = nil, limit: Int = 50) async -> [MemoryService.Hit] { - await memoryService.search(query, projectId: projectId, limit: limit) - } - - @discardableResult - func addMemoryItem(content: String, projectId: UUID?, kind: String = "fact", scope: String = "project") async -> MemoryItem? { - guard memoryEnabled else { return nil } - let item = await memoryService.addMemory( - content: content, - projectId: scope == "global" ? nil : projectId, - sessionId: nil, - sourceMessageId: nil, - kind: normalizedMemoryKind(kind), - scope: normalizedMemoryScope(scope) - ) - if item != nil { memoryRevision &+= 1 } - return item - } - - @discardableResult - func updateMemoryItem(id: String, content: String, projectId: UUID?, kind: String, scope: String) async -> MemoryItem? { - guard memoryEnabled else { return nil } - let item = await memoryService.updateMemory( - id: id, - content: content, - projectId: scope == "global" ? nil : projectId, - sessionId: nil, - sourceMessageId: nil, - kind: normalizedMemoryKind(kind), - scope: normalizedMemoryScope(scope) - ) - if item != nil { memoryRevision &+= 1 } - return item - } - - func deleteMemoryItem(id: String) async { - await memoryService.deleteMemory(id: id) - memoryRevision &+= 1 - } - - func deleteAllMemoryItems(projectId: UUID? = nil) async { - await memoryService.deleteAll(projectId: projectId) - memoryRevision &+= 1 - } - - private func memoryContextSystemPrompt(for hits: [MemoryService.Hit]) -> String { - guard memoryEnabled, memoryInjectEnabled, !hits.isEmpty else { return "" } - let lines = hits.prefix(memoryMaxContextItems).enumerated().map { idx, hit in - "\(idx + 1). \(hit.item.content)" - }.joined(separator: "\n") - return """ - # Relevant user memory - - The notes below are durable user/project memories saved locally in RxCode. Use them as background context for this turn. They may be incomplete or stale; the current user message still has priority. - - \(lines) - """ - } - - private func memoryContextPromptPrefix(for context: String, prompt: String) -> String { - let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return prompt } - return """ - \(trimmed) - - User request: - \(prompt) - """ - } - - private func scheduleMemoryExtraction( - sessionId: String, - projectId: UUID, - messages: [ChatMessage] - ) { - guard memoryEnabled, memoryAutoCreateEnabled else { return } - let userMessage = lastUserMessageText(in: messages) - let finalResponse = lastAssistantResponseText(in: messages) - guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } - let sourceMessageId = messages.last(where: { $0.role == .user && !$0.isError })?.id - let summary = allSessionSummaries.first(where: { $0.id == sessionId }) - ?? summaryFor(sessionId: sessionId, projectId: projectId) - - Task { [weak self] in - guard let self else { return } - await self.extractAndStoreMemories( - sessionId: sessionId, - projectId: projectId, - sourceMessageId: sourceMessageId, - userMessage: userMessage, - finalResponse: finalResponse, - summary: summary - ) - } - } - - private func extractAndStoreMemories( - sessionId: String, - projectId: UUID, - sourceMessageId: UUID?, - userMessage: String, - finalResponse: String, - summary: ChatSession.Summary - ) async { - let relatedHits = await memoryService.search( - "\(userMessage)\n\(finalResponse)", - projectId: projectId, - limit: 6 - ) - let related = relatedHits.map { (id: $0.item.id, content: $0.item.content) } - guard let raw = await generateMemoryOperations( - existingMemories: related, - userMessage: userMessage, - finalResponse: finalResponse, - summary: summary - ) else { return } - let operations = Self.parseMemoryOperations(raw) - guard !operations.isEmpty else { return } - - var changed = false - for operation in operations { - switch operation.action { - case "add": - guard let content = operation.content?.trimmingCharacters(in: .whitespacesAndNewlines), - !content.isEmpty else { continue } - let scope = normalizedMemoryScope(operation.scope) - let existing = await memoryService.search(content, projectId: projectId, limit: 1) - if let best = existing.first, best.score > 0.94 { continue } - if await memoryService.addMemory( - content: content, - projectId: scope == "global" ? nil : projectId, - sessionId: sessionId, - sourceMessageId: sourceMessageId, - kind: normalizedMemoryKind(operation.kind), - scope: scope - ) != nil { - changed = true - } - case "update": - guard let id = operation.id, - let content = operation.content?.trimmingCharacters(in: .whitespacesAndNewlines), - !content.isEmpty else { continue } - let scope = normalizedMemoryScope(operation.scope) - if await memoryService.updateMemory( - id: id, - content: content, - projectId: scope == "global" ? nil : projectId, - sessionId: sessionId, - sourceMessageId: sourceMessageId, - kind: normalizedMemoryKind(operation.kind), - scope: scope - ) != nil { - changed = true - } - case "delete": - guard let id = operation.id else { continue } - await memoryService.deleteMemory(id: id) - changed = true - default: - continue - } - } - if changed { - memoryRevision &+= 1 - } - } - - private struct MemoryOperation { - let action: String - let id: String? - let content: String? - let kind: String? - let scope: String? - } - - private static func parseMemoryOperations(_ raw: String) -> [MemoryOperation] { - let trimmed = stripJSONFence(raw) - guard let range = jsonArrayRange(in: trimmed) else { return [] } - let json = String(trimmed[range]) - guard let data = json.data(using: .utf8), - let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] - else { return [] } - return array.compactMap { entry in - guard let action = entry["action"] as? String else { return nil } - return MemoryOperation( - action: action.lowercased(), - id: entry["id"] as? String, - content: entry["content"] as? String, - kind: entry["kind"] as? String, - scope: entry["scope"] as? String - ) - } - } - - private static func stripJSONFence(_ raw: String) -> String { - var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if text.hasPrefix("```") { - var lines = text.components(separatedBy: "\n") - if !lines.isEmpty { lines.removeFirst() } - if lines.last?.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { - lines.removeLast() - } - text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } - return text - } - - private static func jsonArrayRange(in text: String) -> Range? { - guard let start = text.firstIndex(of: "["), - let end = text.lastIndex(of: "]"), - start <= end else { return nil } - return start.. String { - switch value?.lowercased() { - case "preference", "decision", "fact": - return value!.lowercased() - default: - return "fact" - } - } - - private func normalizedMemoryScope(_ value: String?) -> String { - value?.lowercased() == "global" ? "global" : "project" - } - - // MARK: - Agent Backends - - /// Looks up the `AgentBackend` for the given provider. Used by - /// `processStream`/`cancel`/`finalize` to dispatch via the unified - /// protocol instead of switching on the enum directly. - func backend(for provider: AgentProvider) -> any AgentBackend { - switch provider { - case .claudeCode: return claude - case .codex: return codex - case .acp: return acp - } - } - - // 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 - - func refreshMCPServers() async { - mcpIsLoading = true - mcpListError = nil - defer { mcpIsLoading = false } - do { - // Pass the active project so Settings can show global defaults plus - // the effective per-project override state. - let list = try await mcp.list(projectPath: activeProjectPath) - // Preserve last-known status for rows that already exist so a list - // refresh doesn't visually downgrade everything to .unknown. - var merged: [MCPServerInfo] = [] - merged.reserveCapacity(list.count) - for info in list { - if let existing = mcpServers.first(where: { $0.id == info.id }) { - merged.append(MCPServerInfo( - name: info.name, - transport: info.transport, - endpoint: info.endpoint, - status: existing.status, - scope: info.scope, - projectPath: info.projectPath, - isGloballyEnabled: info.isGloballyEnabled, - projectOverride: info.projectOverride, - effectiveEnabled: info.effectiveEnabled - )) - } else { - merged.append(info) - } - } - mcpServers = merged - } catch { - mcpListError = error.localizedDescription - logger.error("MCP list failed: \(error.localizedDescription, privacy: .public)") - } - } - - func probeMCPServer(name: String) async { - guard let info = mcpServers.first(where: { $0.name == name }) else { - // Fall back to a name-only probe if the row hasn't loaded yet. - await probeMCPServer(id: name, name: name, lookup: { await self.mcp.probe(name: name, projectPath: self.activeProjectPath) }) - return - } - await probeMCPServer(info: info) - } - - /// Probe one specific row. Use this when the same server name appears in - /// multiple scopes/projects (aggregated Settings list) so the right - /// configuration is resolved. - func probeMCPServer(info: MCPServerInfo) async { - await probeMCPServer(id: info.id, name: info.name, lookup: { await self.mcp.probe(info: info) }) - } - - private func probeMCPServer(id: String, name: String, lookup: @escaping () async -> MCPProbeResult) async { - guard !mcpInFlightProbes.contains(id) else { return } - mcpInFlightProbes.insert(id) - defer { mcpInFlightProbes.remove(id) } - - let previousStatus: MCPStatus? = mcpServers.first(where: { $0.id == id })?.status - let result = await lookup() - mcpProbeResults[id] = result - - let newStatus: MCPStatus = result.ok - ? .connected - : .failed(result.error ?? "Probe failed") - - if let idx = mcpServers.firstIndex(where: { $0.id == id }) { - let row = mcpServers[idx] - mcpServers[idx] = MCPServerInfo( - name: row.name, - transport: row.transport, - endpoint: row.endpoint, - status: newStatus, - scope: row.scope, - projectPath: row.projectPath, - isGloballyEnabled: row.isGloballyEnabled, - projectOverride: row.projectOverride, - effectiveEnabled: row.effectiveEnabled - ) - } - - // 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 - { - let notifyService = NotificationService.shared - let serverName = name - Task { @MainActor in - await notifyService.postMCPDisconnected(name: serverName, error: message) - } - } - } - - @discardableResult - func addMCPServer(spec: MCPServerSpec, scope: MCPScope) async -> String? { - do { - try await mcp.add(spec: spec, scope: scope, projectPath: activeProjectPath) - await refreshMCPServers() - // Auto-probe on add so the new row shows live status and tool list - // without the user clicking Test. - await probeMCPServer(name: spec.name) - return nil - } catch { - return error.localizedDescription - } - } - - @discardableResult - func removeMCPServer(name: String, scope: MCPScope) async -> String? { - do { - try await mcp.remove(name: name, scope: scope) - // Clear the probe entry that belonged to the removed row. The id - // shape is `:[:]` — drop any whose name - // suffix and scope prefix match what we just removed. - let scopePrefix = "\(scope.rawValue):" - mcpProbeResults = mcpProbeResults.filter { key, _ in - !(key.hasPrefix(scopePrefix) && key.hasSuffix(":\(name)")) - } - await refreshMCPServers() - return nil - } catch { - return error.localizedDescription - } - } - - @discardableResult - func setMCPServerGlobalEnabled(name: String, enabled: Bool) async -> String? { - do { - try await mcp.setGlobalEnabled(name: name, enabled: enabled) - await refreshMCPServers() - return nil - } catch { - return error.localizedDescription - } - } - - @discardableResult - func setMCPServerProjectOverride(name: String, override: MCPProjectOverride) async -> String? { - guard let activeProjectPath else { - return "No active project selected." - } - do { - try await mcp.setProjectOverride(name: name, projectPath: activeProjectPath, override: override) - await refreshMCPServers() - return nil - } catch { - return error.localizedDescription - } - } - - /// Spawn the periodic MCP probe loop. Idempotent. - func startMCPPeriodicProbe() { - guard mcpPeriodicProbeTask == nil else { return } - mcpPeriodicProbeTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: AppState.mcpPeriodicProbeInterval) - guard !Task.isCancelled else { return } - await self?.refreshAndProbeAllMCPServers() - } - } - } - - /// Refresh the MCP server list and probe every server concurrently. - /// Used at app launch (and on a 5-minute timer) so the Settings sheet shows - /// live status without the user having to click "Test" on each row. - func refreshAndProbeAllMCPServers() async { - await refreshMCPServers() - let snapshot = mcpServers - guard !snapshot.isEmpty else { return } - await withTaskGroup(of: Void.self) { group in - for info in snapshot { - group.addTask { [weak self] in - await self?.probeMCPServer(info: info) - } - } - } - } - - // MARK: - Private State - - // MARK: - Window-Scoped Session State Accessors - - func streamState(in window: WindowState) -> SessionStreamState { - sessionStates[window.currentSessionId ?? window.newSessionKey] ?? SessionStreamState() - } - - func messages(in window: WindowState) -> [ChatMessage] { - streamState(in: window).messages - } - - /// File edits accumulated across this thread, sourced from SwiftData. - /// Returns an empty array for a not-yet-persisted (placeholder) session. - func threadFileEdits(in window: WindowState) -> [FileEditSummary] { - let key = window.currentSessionId ?? window.newSessionKey - return threadStore.fetchFileEdits(sessionId: key).map { $0.toSummary() } - } - - func isStreaming(in window: WindowState) -> Bool { - streamState(in: window).isStreaming - } - - func isThinking(in window: WindowState) -> Bool { - streamState(in: window).isThinking - } - - func streamingStartDate(in window: WindowState) -> Date? { - streamState(in: window).streamingStartDate - } - - func activeModelName(in window: WindowState) -> String? { - streamState(in: window).activeModelName - } - - func lastTurnContextUsedPercentage(in window: WindowState) -> Double? { - streamState(in: window).lastTurnContextUsedPercentage - } - - func sessionCostUsd(in window: WindowState) -> Double { - streamState(in: window).costUsd - } - - func sessionTurns(in window: WindowState) -> Int { - streamState(in: window).turns - } - - func sessionInputTokens(in window: WindowState) -> Int { - streamState(in: window).inputTokens - } - - func sessionOutputTokens(in window: WindowState) -> Int { - streamState(in: window).outputTokens - } - - func sessionCacheCreationTokens(in window: WindowState) -> Int { - streamState(in: window).cacheCreationTokens - } - - func sessionCacheReadTokens(in window: WindowState) -> Int { - streamState(in: window).cacheReadTokens - } - - func sessionDurationMs(in window: WindowState) -> Double { - streamState(in: window).durationMs - } - - func currentSession(in window: WindowState) -> ChatSession? { - guard let id = window.currentSessionId else { return nil } - guard let summary = allSessionSummaries.first(where: { $0.id == id }) else { return nil } - return summary.makeSession() - } - - /// Check whether a given session is streaming in the background (not foreground) of this window - func isBackgroundStreaming(_ sessionId: String, in window: WindowState) -> Bool { - guard sessionId != (window.currentSessionId ?? window.newSessionKey) else { return false } - return sessionStates[sessionId]?.isStreaming ?? false - } - - /// Returns the set of session IDs currently streaming in the background of this window. - func backgroundStreamingSessionIds(in window: WindowState) -> Set { - let currentKey = window.currentSessionId ?? window.newSessionKey - return Set(sessionStates.compactMap { key, state in - (state.isStreaming && key != currentKey) ? key : nil - }) - } - - /// Derive a UI status for the chat row in the project sidebar. - func chatStatus(forSessionId id: String, in window: WindowState) -> ChatStatus { - if window.pendingPermissions.contains(where: { $0.sessionId == id }) { - return .awaitingPermission - } - if let state = sessionStates[id] { - if state.isStreaming { return .streaming } - if state.hasUncheckedCompletion { return .done } - } - return .idle - } - - func todoProgress(forSessionId id: String) -> ChatTodoProgress? { - if let messages = sessionStates[id]?.messages, - let todos = TodoExtractor.latest(in: messages) - { - return ChatTodoProgress(todos: todos) - } - - guard let snapshot = threadStore.fetchTodoSnapshot(sessionId: id), snapshot.total > 0 else { - return nil - } - - return ChatTodoProgress( - done: snapshot.done, - total: snapshot.total, - inProgress: snapshot.inProgress > 0 - ) - } - - // MARK: - Initialization - - /// Once per app launch — start services and load shared data - func initialize() async { - ThemeStore.shared.current = selectedTheme - ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment - ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment - - // Supply MobileSyncService with desktop-side context for the mobile - // job Live Activity and home-screen widget pushes. - MobileSyncService.shared.projectNameResolver = { [weak self] id in - self?.projects.first { $0.id == id }?.name - } - MobileSyncService.shared.usageSnapshotProvider = { [weak self] in - (self?.latestRateLimitUsage?.fiveHourPercent, - self?.latestCodexRateLimitUsage?.fiveHourPercent) - } - - await refreshAgentInstallations() - - projects = await persistence.loadProjects() - var seenPaths = Set() - let deduplicated = projects.filter { seenPaths.insert($0.path).inserted } - if deduplicated.count != projects.count { - projects = deduplicated - try? await persistence.saveProjects(projects) - } - - if let cachedUser = await persistence.loadGitHubUser() { - gitHubUser = cachedUser - isLoggedIn = true - _ = await github.loadToken() - } - - customRepos = await persistence.loadCustomRepos() - marketplaceCustomSources = await marketplace.customSources() - - // Sidebar threads are now sourced from the local SwiftData store. - // CLI session files are no longer surfaced in the sidebar list — the - // CLI is still the transcript backend (replay on thread open), but - // it does not drive thread discovery. - allSessionSummaries = threadStore.loadAllSummaries() - autoArchiveExpiredSessionsIfNeeded() - purgeStaleBranchBriefingsIfNeeded() - - persistedQueues = threadStore.loadAllQueues() - - 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 { - try await permission.start() - } catch { - logger.error("Failed to start permission server: \(error.localizedDescription)") - } - - // Warm MCP server statuses in the background so the Settings sheet - // shows live connection results without the user clicking "Test". - Task { [weak self] in - await self?.refreshAndProbeAllMCPServers() - } - - // Warm the rate-limit usage so the menu-bar label has data before the - // popover is opened. RateLimitService caches for 5 minutes internally. - Task { [weak self] in - await self?.refreshRateLimitUsage() - await self?.refreshCodexRateLimitUsage() - } - - // Recurring probe so disconnected MCP servers surface promptly even - // when the user isn't actively interacting with the Settings tab. - startMCPPeriodicProbe() - - // Permission request routing is handled per-window in initializeWindow's listener. - - isInitialized = true - } - - func refreshAgentInstallations() async { - let claudeBinary = await claude.findClaudeBinary() - claudeBinaryPath = claudeBinary - claudeInstalled = claudeBinary != nil - claudeVersion = nil - - if claudeBinary != nil { - do { - claudeVersion = try await claude.checkVersion() - } catch { - logger.warning("Failed to fetch Claude CLI version: \(error.localizedDescription)") - } - } - - let codexBinary = await codex.findCodexBinary() - codexBinaryPath = codexBinary - codexInstalled = codexBinary != nil - codexVersion = nil - - if codexBinary != nil { - do { - codexVersion = try await codex.checkVersion() - codexModels = await codex.fetchModels() - logger.info("Codex CLI detected; fetched \(self.codexModels.count) Codex models") - if codexModels.isEmpty { - logger.warning("Codex model discovery returned empty; using built-in Codex fallback models") - } - Task { [weak self] in - await self?.refreshCodexRateLimitUsage() - } - } catch { - logger.warning("Failed to fetch Codex CLI version or models: \(error.localizedDescription)") - } - } else { - codexModels = [] - logger.info("Codex CLI not detected; Codex model list cleared") - } - } - - func refreshOpenAISummarizationModels() async { - let endpoint = openAISummarizationEndpoint - let apiKey = openAISummarizationAPIKey - - isLoadingOpenAISummarizationModels = true - openAISummarizationModelsError = nil - defer { isLoadingOpenAISummarizationModels = false } - - do { - let models = try await openAISummarization.fetchModels(endpoint: endpoint, apiKey: apiKey) - openAISummarizationModels = models - if openAISummarizationModel.isEmpty || !models.contains(openAISummarizationModel) { - openAISummarizationModel = models.first ?? "" - } - } catch { - openAISummarizationModelsError = error.localizedDescription - logger.warning("Failed to fetch OpenAI summarization models: \(error.localizedDescription)") - } - } - - /// Per-window initialization — restore selected project and load session history - func initializeWindow(_ window: WindowState, selectingProjectId: UUID? = nil) async { - // Subscribe to permission broadcasts — appends requests to this window's pendingPermissions. - // subscribe() issues a window-exclusive stream, so events are not stolen across multiple windows. - Task { [weak self, weak window] in - guard let self else { return } - let (_, stream) = await self.permission.subscribe() - for await request in stream { - guard !Task.isCancelled else { break } - guard let window else { break } - if !window.pendingPermissions.contains(where: { $0.id == request.id }) { - window.pendingPermissions.append(request) - mobilePendingRequests[request.id] = request - let projectName = window.selectedProject?.name - let projectId = window.selectedProject?.id - let sessionId = window.currentSessionId - let toolName = request.toolName - if let requestSessionId = request.sessionId { - broadcastMobileSessionStatus(sessionID: requestSessionId) - } - if toolName == "AskUserQuestion" { - broadcastMobileQuestionQueue() - } - // Auto-present the question sheet only when the user is actively viewing - // the thread the question belongs to. Otherwise it stays in the queue - // (yellow dot in sidebar + banner) so the user can decide when to answer. - if toolName == "AskUserQuestion", - window.presentedPermissionId == nil, - let qSession = request.sessionId, - qSession == window.currentSessionId - { - window.presentedPermissionId = request.id - } - Task { @MainActor in - if toolName == "AskUserQuestion" { - await NotificationService.shared.postQuestionNeeded( - projectName: projectName, - projectId: projectId, - sessionId: sessionId - ) - } else { - await NotificationService.shared.postPermissionNeeded( - toolName: toolName, - projectName: projectName, - projectId: projectId, - sessionId: sessionId - ) - } - } - } - } - } - - // Install the AskUserQuestion handlers. The question sheet calls submit when the - // user finishes answering, and skip when they dismiss without answering. - window.submitQuestionAnswersHandler = { [weak self, weak window] toolUseId, answers in - guard let self, let window else { return } - Task { await self.respondToAskUserQuestion(toolUseId: toolUseId, answers: answers, in: window) } - } - window.skipQuestionHandler = { [weak self, weak window] toolUseId in - guard let self, let window else { return } - Task { await self.skipAskUserQuestion(toolUseId: toolUseId, in: window) } - } - - // Install the plan-card decision handler. The buttons on `PlanCardView` route - // through here to resolve the ExitPlanMode hook and apply any follow-up mode change. - window.planDecisionHandler = { [weak self, weak window] toolUseId, action in - guard let self, let window else { return } - Task { await self.respondToPlanDecision(toolUseId: toolUseId, action: action, in: window) } - } - - // Hydrate per-window draft queues from disk-persisted queues so messages - // typed-while-streaming survive an app relaunch. - for (key, queue) in persistedQueues where window.draftQueues[key] == nil { - window.draftQueues[key] = queue - } - - if let projectId = selectingProjectId, - 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 }) - { - selectProject(project, in: window) - } else if let first = projects.first { - selectProject(first, in: window) - } - - // Show the briefing as the landing view on launch, even after restoring a project. - // `selectProject` clears `showingBriefing` for normal switches; re-enable here so the - // user lands on the briefing dashboard rather than a fresh chat. - window.showingBriefing = true - - window.isInitialized = true - } - - // MARK: - ChatBridge Setup - - /// Configures a `ChatBridge`'s action handlers and starts an observation loop that keeps - /// the bridge's state properties in sync with the underlying `sessionStates`. - func setupChatBridge(_ bridge: ChatBridge, for window: WindowState) { - registerLiveWindow(window) - bridge.sendHandler = { [weak self, weak window] in - guard let self, let window else { return } - await self.send(in: window) - } - bridge.cancelStreamingHandler = { [weak self, weak window] in - guard let self, let window else { return } - await self.cancelStreaming(in: window) - } - bridge.sendSlashCommandHandler = { [weak self, weak window] command in - guard let self, let window else { return } - await self.sendSlashCommand(command, in: window) - } - bridge.runTerminalCommandHandler = { [weak self, weak window] command in - guard let self, let window else { return } - await self.runTerminalCommand(command, in: window) - } - bridge.editAndResendHandler = { [weak self, weak window] messageId, newContent in - guard let self, let window else { return } - await self.editAndResend(messageId: messageId, newContent: newContent, in: window) - } - bridge.fetchRateLimitHandler = { [weak self] provider in - await self?.rateLimitUsage(for: provider) - } - bridge.setSessionProviderHandler = { [weak self, weak window] provider in - guard let self, let window else { return } - self.setSessionProvider(provider, in: window) - } - bridge.togglePlanModeHandler = { [weak self, weak window] in - guard let self, let window else { return } - self.toggleSessionPlanMode(in: window) - } - bridge.enqueueMessageHandler = { [weak self, weak window] text, attachments in - guard let self, let window else { return } - self.enqueueMessage(text: text, attachments: attachments, in: window) - } - bridge.removeQueuedMessageHandler = { [weak self, weak window] id in - guard let self, let window else { return } - self.removeQueuedMessage(id: id, in: window) - } - bridge.dequeueNextForFlushHandler = { [weak self, weak window] in - guard let self, let window else { return nil } - return self.dequeueNextForFlush(in: window) - } - bridge.sendQueuedNowHandler = { [weak self, weak window] id in - guard let self, let window else { return } - await self.sendQueuedNow(id: id, in: window) - } - bridge.sendAllQueuedAsOneHandler = { [weak self, weak window] in - guard let self, let window else { return } - await self.sendAllQueuedAsOne(in: window) - } - - startBridgeObservation(bridge, for: window) - } - - /// Runs a reactive observation loop: reads AppState + WindowState properties into the bridge, - /// then re-registers after each change. Stops when the bridge or window is deallocated. - private func startBridgeObservation(_ bridge: ChatBridge, for window: WindowState) { - // Streaming state and global settings are observed in separate loops so that frequent - // streaming updates don't trigger settings re-pushes (and vice versa). - func observeStream() { - withObservationTracking { - let state = streamState(in: window) - if bridge.messages.count != state.messages.count || bridge.isLoadingFromDisk != state.isLoadingFromDisk { - self.logger.info("[Bridge.observe] push sid=\(window.currentSessionId ?? "", privacy: .public) messages \(bridge.messages.count)→\(state.messages.count) loading \(bridge.isLoadingFromDisk)→\(state.isLoadingFromDisk) streaming=\(state.isStreaming)") - } - bridge.messages = state.messages - bridge.isStreaming = state.isStreaming - bridge.isThinking = state.isThinking - bridge.isLoadingFromDisk = state.isLoadingFromDisk - bridge.streamingStartDate = state.streamingStartDate - bridge.liveOutputTokens = state.currentTurnOutputTokens - bridge.lastTurnContextUsedPercentage = state.lastTurnContextUsedPercentage - let selection = effectiveModelSelection(in: window) - let provider = selection.provider - let currentModel = selection.model - bridge.agentProvider = provider - bridge.modelDisplayName = modelDisplayName(for: currentModel, provider: provider, in: window) - bridge.sessionStats = ChatSessionStats( - costUsd: state.costUsd, - inputTokens: state.inputTokens, - outputTokens: state.outputTokens, - cacheCreationTokens: state.cacheCreationTokens, - cacheReadTokens: state.cacheReadTokens, - durationMs: state.durationMs, - turns: state.turns - ) - bridge.planDecisionSummaries = state.planDecisionSummaries - } onChange: { - Task { @MainActor in observeStream() } - } - } - func observeSettings() { - withObservationTracking { - bridge.autoPreviewSettings = self.autoPreviewSettings - bridge.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" - bridge.claudeVersion = self.claudeVersion - bridge.codexVersion = self.codexVersion - } onChange: { - Task { @MainActor in observeSettings() } - } - } - Task { @MainActor in observeStream() } - Task { @MainActor in observeSettings() } - } - - // MARK: - Edit & Resend - - func editAndResend(messageId: UUID, newContent: String, in window: WindowState) async { - let key = window.currentSessionId ?? window.newSessionKey - var snapshot = sessionStates[key]?.messages ?? [] - guard let index = snapshot.firstIndex(where: { $0.id == messageId }), - snapshot[index].role == .user else { return } - - let trimmed = newContent.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - if isStreaming(in: window) { - await cancelStreaming(in: window) - } - - snapshot.removeSubrange((index + 1)...) - snapshot[index].content = trimmed - - window.currentSessionId = nil - sessionStates.removeValue(forKey: window.newSessionKey) - await sendPrompt(trimmed, skipAppendingUserMessage: true, initialMessages: snapshot, in: window) - } - - // MARK: - Send Message - - func send(in window: WindowState) async { - let prompt = window.inputText.trimmingCharacters(in: .whitespacesAndNewlines) - let currentAttachments = window.attachments - guard !prompt.isEmpty || !currentAttachments.isEmpty else { return } - - // S2: warn (in logs) if another process touched the same jsonl very - // recently — likely a `claude` running in the terminal on the same - // session. We don't block, but the operator can spot it after the fact. - if effectiveModelSelection(in: window).provider == .claudeCode, - let sid = window.currentSessionId, - let cwd = window.selectedProject?.path, - 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") - } - - if currentAttachments.isEmpty, await handleNativeSlashCommand(prompt, in: window) { - window.inputText = "" - return - } - - window.inputText = "" - window.draftTexts.removeValue(forKey: draftKey(for: window)) - window.attachments = [] - - let (resolvedAttachments, tempFilePaths) = AttachmentFactory.resolvingClipboardImages(currentAttachments) - let fullPrompt = buildPromptWithAttachments(prompt, attachments: resolvedAttachments) - - await sendPrompt(fullPrompt, displayText: prompt, attachments: resolvedAttachments, - tempFilePaths: tempFilePaths, in: window) - } - - /// Slash commands handled natively. Returns true if handled. - private func handleNativeSlashCommand(_ text: String, in window: WindowState) async -> Bool { - guard text.hasPrefix("/") else { return false } - let parts = text.split(separator: " ", maxSplits: 1) - let command = parts.first.map { String($0.dropFirst()) } ?? "" - - switch command { - case "clear": - startNewChat(in: window) - return true - case "model": - if parts.count > 1 { - let arg = String(parts[1]).trimmingCharacters(in: .whitespaces).lowercased() - let flattened = availableAgentModelSections().flatMap(\.models) - let matched = flattened.first { $0.id.lowercased() == arg } - ?? flattened.first { arg.contains($0.id.lowercased()) } - setSessionModel(matched?.id ?? arg, provider: matched?.provider, in: window) - } else { - window.showModelPicker = true - } - return true - case "effort": - if parts.count > 1 { - let arg = String(parts[1]).trimmingCharacters(in: .whitespaces).lowercased() - setSessionEffort(Self.availableEfforts.contains(arg) ? arg : nil, in: window) - } else { - window.showEffortPicker = true - } - return true - default: - return false - } - } - - // MARK: - Send Slash Command - - func sendSlashCommand(_ command: String, in window: WindowState) async { - let trimmed = command.trimmingCharacters(in: .whitespaces) - if await handleNativeSlashCommand(trimmed, in: window) { return } - - let baseName = trimmed.split(separator: " ", maxSplits: 1) - .first.map { String($0.dropFirst()) } ?? "" - - let isInteractive = SlashCommandRegistry.commands - .first { $0.name == baseName }?.isInteractive ?? false - - if isInteractive { - await sendInteractiveCommand(trimmed, in: window) - } else { - await sendPrompt(trimmed, in: window) - } - } - - private func sendInteractiveCommand(_ command: String, in window: WindowState) async { - let title = command.trimmingCharacters(in: .whitespaces) - await launchTerminal(title: title, initialCommand: command, in: window) - } - - func runTerminalCommand(_ command: String, in window: WindowState) async { - let title = command.trimmingCharacters(in: .whitespaces) - await launchTerminal(title: title, initialCommand: command, rawShell: true, in: window) - } - - func openTerminal(in window: WindowState) async { - // Right sidebar visibility lives in UserDefaults (read via @AppStorage - // in views). Writing here triggers those views to update. - let defaults = UserDefaults.standard - let key = AppStorageKeys.showRightSidebar - if defaults.bool(forKey: key), window.inspectorTab == .terminal { - defaults.set(false, forKey: key) - } else { - window.inspectorTab = .terminal - defaults.set(true, forKey: key) - } - } - - private func launchTerminal( - title: String, - initialCommand: String? = nil, - reportToChat: Bool = true, - rawShell: Bool = false, - in window: WindowState - ) async { - guard let project = window.selectedProject else { - handleError(AppError.noProjectSelected, in: window) - return - } - - let arguments: [String] - if rawShell { - arguments = ["-il"] - } else { - guard let binary = await claude.findClaudeBinary() else { - handleError(AppError.claudeNotInstalled, in: window) - return - } - arguments = ["-ilc", binary] - } - - window.interactiveTerminal = InteractiveTerminalState( - title: title, - executable: "/bin/zsh", - arguments: arguments, - currentDirectory: project.path, - initialCommand: initialCommand, - reportToChat: reportToChat - ) - } - - func dismissInteractiveTerminal(exitCode: Int32, in window: WindowState) { - guard let terminal = window.interactiveTerminal else { return } - window.interactiveTerminal = nil - - guard terminal.reportToChat else { return } - - let key = window.currentSessionId ?? window.newSessionKey - let wasFirstUserMessage = (sessionStates[key]?.messages.filter { $0.role == .user }.count ?? 0) == 0 - updateState(key) { state in - state.messages.append(ChatMessage(role: .user, content: terminal.title)) - let result = exitCode == 0 ? "Done" : "exit code: \(exitCode)" - let toolCall = ToolCall( - id: UUID().uuidString, - name: InteractiveTerminalState.toolName, - input: ["command": .string(terminal.title)], - result: result, - isError: exitCode != 0 - ) - state.messages.append(ChatMessage(role: .assistant, blocks: [.toolCall(toolCall)])) - } - if wasFirstUserMessage, !(sessionStates[key]?.titleGenerationTriggered ?? false) { - updateState(key) { $0.titleGenerationTriggered = true } - Task { [weak self] in - guard let self else { return } - await self.maybeGenerateLLMTitle(for: key) - } - } - Task { await saveCurrentSession(in: window) } - } - - // MARK: - Shared Send Logic - - @discardableResult - private func sendPrompt( - _ prompt: String, - displayText: String? = nil, - attachments: [Attachment] = [], - skipAppendingUserMessage: Bool = false, - initialMessages: [ChatMessage]? = nil, - tempFilePaths: [String] = [], - in window: WindowState - ) async -> UUID? { - guard let project = window.selectedProject else { - handleError(AppError.noProjectSelected, in: window) - return nil - } - - if isStreaming(in: window) { - await cancelStreaming(in: window) - } - - let streamId = UUID() - let isNewSession = window.currentSessionId == nil - let isPending = window.currentSessionId.map { window.pendingPlaceholderIds.contains($0) } ?? false - let cliSessionId: String? = (isNewSession || isPending) ? nil : window.currentSessionId - - if isNewSession { - let tempId = "pending-\(streamId.uuidString)" - window.currentSessionId = tempId - window.insertPendingPlaceholder(tempId) - let snapSelection = effectiveModelSelection(in: window) - let snapProvider = snapSelection.provider - let snapModel = snapSelection.model - window.sessionAgentProvider = snapProvider - window.sessionModel = snapModel - let snapEffort = window.sessionEffort - let snapPermission = window.sessionPermissionMode - let pendingWorktreePath = window.pendingWorktreePath - let pendingWorktreeBranch = window.pendingWorktreeBranch - updateState(tempId) { state in - state.agentProvider = snapProvider - state.model = snapModel - state.effort = snapEffort - state.permissionMode = snapPermission - state.worktreePath = pendingWorktreePath - state.worktreeBranch = pendingWorktreeBranch - } - window.pendingWorktreePath = nil - window.pendingWorktreeBranch = nil - } - - let sessionKey = window.currentSessionId! - - // Apply initialMessages if provided - if let initial = initialMessages { - updateState(sessionKey) { $0.messages = initial } - } - - let wasFirstUserMessage = (sessionStates[sessionKey]?.messages.filter { $0.role == .user }.count ?? 0) == 0 - if !skipAppendingUserMessage { - updateState(sessionKey) { state in - state.messages.append(ChatMessage( - role: .user, - content: displayText ?? prompt, - attachments: attachments - )) - state.inFlightUserAttachments = attachments - } - } - - // Insert the placeholder summary before kicking off title generation — - // the Task below awaits and the lookup in maybeGenerateLLMTitle would - // otherwise race the insertion at line ~1168 and bail with "no summary". - if isNewSession { - let initialTitle = ChatSession.placeholderTitle(from: displayText ?? prompt) - let selection = effectiveModelSelection(in: window) - let provider = selection.provider - let placeholder = ChatSession( - id: sessionKey, - projectId: project.id, - title: initialTitle, - messages: [], - agentProvider: provider, - model: selection.model, - origin: provider.defaultSessionOrigin, - worktreePath: sessionStates[sessionKey]?.worktreePath, - worktreeBranch: sessionStates[sessionKey]?.worktreeBranch - ) - allSessionSummaries.insert(placeholder.summary, at: 0) - threadStore.upsert(placeholder.summary) - } - - // Kick off LLM title generation as soon as the first user message lands — - // the rename runs concurrently with the stream so the sidebar title updates - // without waiting for the assistant to reply. - if wasFirstUserMessage, - !skipAppendingUserMessage, - !(sessionStates[sessionKey]?.titleGenerationTriggered ?? false) - { - updateState(sessionKey) { $0.titleGenerationTriggered = true } - let titleKey = sessionKey - Task { [weak self] in - guard let self else { return } - await self.maybeGenerateLLMTitle(for: titleKey) - } - } - - updateState(sessionKey) { state in - state.isStreaming = true - state.hasUncheckedCompletion = false - state.activeStreamId = streamId - state.streamingStartDate = Date() - state.currentTurnOutputTokensByMessage.removeAll(keepingCapacity: true) - state.currentTurnOutputTokensUnkeyed = 0 - } - broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingStarted) - - let basePermissionMode = window.sessionPermissionMode ?? permissionMode - // Plan-mode boolean overrides the dropdown for the CLI `--permission-mode` flag only. - // The dropdown choice is preserved and re-applied automatically once plan-mode is toggled back off. - let cliPermissionMode: PermissionMode = window.sessionPlanMode ? .plan : basePermissionMode - // PermissionServer registration uses the dropdown value directly so an explicit Auto - // choice continues to auto-approve hook-matched tools while plan mode is on. - // ExitPlanMode is always exempt from auto-approve (see PermissionServer.autoApproveReason), - // so the plan card still surfaces. - let hookSessionMode = basePermissionMode - let launchAgentProvider = sessionStates[sessionKey]?.agentProvider - ?? window.sessionAgentProvider - ?? selectedAgentProvider - var hookSettingsPath: String? - if launchAgentProvider == .claudeCode, !cliPermissionMode.skipsHookPipeline { - do { - hookSettingsPath = try await permission.writeHookSettingsFile() - } catch { - logger.error("Failed to write hook settings: \(error.localizedDescription)") - } - } - - // Resume already has the sid; new sessions register on first system event. - if let sid = cliSessionId { - await permission.registerSession(sid: sid, projectKey: project.path, mode: hookSessionMode) - } - - if !isNewSession { - await saveCurrentSession(in: window) - } - - let effectiveCwd = sessionStates[sessionKey]?.worktreePath - ?? allSessionSummaries.first(where: { $0.id == sessionKey })?.worktreePath - ?? project.path - let selection = effectiveModelSelection(in: window) - let effectiveProvider = sessionStates[sessionKey]?.agentProvider ?? selection.provider - let effectiveModel = sessionStates[sessionKey]?.model ?? selection.model - - let task = Task { [weak self, window] in - guard let self else { return } - await self.processStream( - streamId: streamId, - prompt: prompt, - cwd: effectiveCwd, - cliSessionId: cliSessionId, - internalSessionKey: sessionKey, - agentProvider: effectiveProvider, - model: effectiveModel, - effort: window.sessionEffort ?? (self.selectedEffort == "auto" ? nil : self.selectedEffort), - hookSettingsPath: hookSettingsPath, - permissionMode: cliPermissionMode, - hookSessionMode: hookSessionMode, - projectId: project.id, - window: window - ) - for path in tempFilePaths { - try? FileManager.default.removeItem(atPath: path) - } - } - sessionStates[sessionKey, default: SessionStreamState()].streamTask = task - return streamId - } - - // MARK: - Stream Processing - - private func stateForSession(_ key: String) -> SessionStreamState { - sessionStates[key] ?? SessionStreamState() - } - - private func updateState(_ key: String, _ mutate: (inout SessionStreamState) -> Void) { - let prevMessages = sessionStates[key]?.messages ?? [] - let prevThinking = sessionStates[key]?.isThinking ?? false - guard var state = sessionStates[key] else { - var fresh = SessionStreamState() - mutate(&fresh) - sessionStates[key] = fresh - broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: fresh.messages, isStreaming: fresh.isStreaming) - broadcastMobileThinkingChange(sessionKey: key, prev: prevThinking, next: fresh.isThinking, isStreaming: fresh.isStreaming) - return - } - mutate(&state) - sessionStates[key] = state - broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) - broadcastMobileThinkingChange(sessionKey: key, prev: prevThinking, next: state.isThinking, isStreaming: state.isStreaming) - } - - /// Mirror `isThinking` transitions to paired mobile devices so the remote - /// streaming indicator can show a "Thinking…" label. Only fires on an - /// actual change — the flag flips repeatedly within a turn and we don't - /// want to flood the relay with redundant updates. - private func broadcastMobileThinkingChange(sessionKey: String, prev: Bool, next: Bool, isStreaming: Bool) { - guard prev != next, !MobileSyncService.shared.pairedDevices.isEmpty else { return } - MobileSyncService.shared.broadcastSessionUpdate( - sessionID: sessionKey, - kind: .statusChanged, - message: nil, - isStreaming: isStreaming, - isThinking: next - ) - } - - private func finalizeStreamSession( - for sessionKey: String, - extraMutations: ((inout SessionStreamState) -> Void)? = nil - ) { - flushPendingUpdates(for: sessionKey) - updateState(sessionKey) { state in - state.flushTask?.cancel() - state.flushTask = nil - state.isStreaming = false - state.isThinking = false - state.needsNewMessage = false - state.activeStreamId = nil - state.streamTask = nil - state.activeToolId = nil - state.activeToolInputBuffer = "" - state.textDeltaBuffer = "" - state.pendingToolResults.removeAll() - - extraMutations?(&state) - - if let idx = state.messages.indices.reversed().first(where: { - state.messages[$0].role == .assistant && state.messages[$0].isStreaming - }) { - state.messages[idx].isStreaming = false - state.messages[idx].isResponseComplete = true - state.messages[idx].finalizeToolCalls() - if let start = state.streamingStartDate { - state.messages[idx].duration = Date().timeIntervalSince(start) - } - Self.stripNoOpText(at: idx, in: &state.messages) - } - state.streamingStartDate = nil - } - broadcastMobileSessionStatus(sessionID: sessionKey, kind: .streamingFinished) - Task { @MainActor [weak self] in - await self?.flushNextQueuedMessageIfNeeded(sessionID: sessionKey) - } - } - - // MARK: - Stream Completion (cross-project MCP) - - /// Record that the stream `streamId` finished. Stored in - /// `pendingStreamCompletions` for any `awaitStreamCompletion(...)` caller - /// (currently `ide__send_to_thread`) to pick up. Latest call wins, except - /// we don't overwrite a success with an error from the fallback path. - private func recordStreamCompletion( - streamId: UUID, - sessionId: String, - assistantText: String, - error: String? - ) { - pendingStreamCompletions[streamId] = StreamCompletion( - sessionId: sessionId, - assistantText: assistantText, - error: error - ) - } - - /// Wait up to `timeout` seconds for the stream identified by `streamId` - /// to record a completion. Polls every 100ms — MainActor serialization - /// means the recorder fires between sleeps. Returns the completion if - /// one arrived in time, otherwise `nil`. - func awaitStreamCompletion(streamId: UUID, timeout: TimeInterval) async -> StreamCompletion? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let completion = pendingStreamCompletions.removeValue(forKey: streamId) { - return completion - } - try? await Task.sleep(nanoseconds: 100_000_000) - } - return pendingStreamCompletions.removeValue(forKey: streamId) - } - - /// Discard a recorded completion. Called by long-running `wait_for_response=false` - /// MCP sends so the dictionary doesn't grow unbounded with abandoned results. - func discardStreamCompletion(streamId: UUID) { - pendingStreamCompletions.removeValue(forKey: streamId) - } - - // MARK: - Cross-Project Send (used by ide__send_to_thread) - - struct CrossProjectSendResult: Sendable { - let threadId: String - let projectId: UUID - let done: Bool - let assistantText: String - let error: String? - } - - enum CrossProjectSendError: Error, LocalizedError { - case unknownProject(UUID) - case unknownThread(String) - - var errorDescription: String? { - switch self { - case .unknownProject(let id): return "No project with id \(id.uuidString)" - case .unknownThread(let id): return "No thread with id \(id)" - } - } - } - - /// Send a prompt to a thread in any project. The send runs through the - /// normal `sendPrompt` pipeline via a synthetic `WindowState`, so all the - /// usual side-effects (title generation, briefing updates, persistence) - /// still fire and any UI windows currently bound to the same session see - /// the assistant tokens live via the shared `sessionStates` dictionary. - func sendCrossProject( - projectId: UUID?, - threadId: String?, - prompt: String, - agentProvider: AgentProvider? = nil, - model: String? = nil, - effort: String? = nil, - permissionMode: PermissionMode? = nil, - waitForResponse: Bool = true, - timeoutSeconds: TimeInterval = 120 - ) async throws -> CrossProjectSendResult { - // Resolve target project + thread. - let resolvedProject: Project - let resolvedThreadId: String? - - if let threadId { - guard let summary = allSessionSummaries.first(where: { $0.id == threadId }) - ?? threadStore.fetch(id: threadId).map({ $0.toSummary() }) - else { - throw CrossProjectSendError.unknownThread(threadId) - } - guard let proj = projects.first(where: { $0.id == summary.projectId }) else { - throw CrossProjectSendError.unknownProject(summary.projectId) - } - resolvedProject = proj - resolvedThreadId = threadId - } else if let projectId { - guard let proj = projects.first(where: { $0.id == projectId }) else { - throw CrossProjectSendError.unknownProject(projectId) - } - resolvedProject = proj - resolvedThreadId = nil - } else { - throw CrossProjectSendError.unknownProject(UUID()) - } - - // Build a synthetic WindowState. AppState.sessionStates is shared across - // windows, so the message + stream are visible to any real window that - // happens to also be viewing this session. - let window = WindowState() - window.selectedProject = resolvedProject - window.currentSessionId = resolvedThreadId - - // Carry over per-session overrides for a new thread; for an existing - // thread we leave the session's own stored values alone (the resume - // path in sendPrompt reads from `sessionStates[sessionKey]`). - if resolvedThreadId == nil { - if let agentProvider { - window.sessionAgentProvider = agentProvider - } - if let model { - window.sessionModel = model - } - if let effort { - window.sessionEffort = effort - } - if let permissionMode { - window.sessionPermissionMode = permissionMode - } - } - - guard let streamId = await sendPrompt(prompt, displayText: prompt, in: window) else { - return CrossProjectSendResult( - threadId: resolvedThreadId ?? "", - projectId: resolvedProject.id, - done: false, - assistantText: "", - error: "Send failed: no session could be allocated." - ) - } - - // After sendPrompt returns, window.currentSessionId is the (possibly - // pending-) key the stream is bound to. The CLI may rename it to its - // own sid mid-stream; we surface whichever id the completion lands on. - let postSendThreadId = window.currentSessionId ?? resolvedThreadId ?? "" - - if !waitForResponse { - // Don't leak the result in the dictionary — the caller is - // fire-and-forget. Drop it once it lands. - Task { [weak self] in - _ = await self?.awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) - } - return CrossProjectSendResult( - threadId: postSendThreadId, - projectId: resolvedProject.id, - done: false, - assistantText: "", - error: nil - ) - } - - let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) - if let completion { - return CrossProjectSendResult( - threadId: completion.sessionId, - projectId: resolvedProject.id, - done: completion.error == nil, - assistantText: completion.assistantText, - error: completion.error - ) - } else { - // Timed out. Surface the partial assistant text we have so far so - // the caller can decide whether to poll back via get_thread_messages. - let partial = lastAssistantResponseText(in: stateForSession(window.currentSessionId ?? "").messages) - return CrossProjectSendResult( - threadId: window.currentSessionId ?? postSendThreadId, - projectId: resolvedProject.id, - done: false, - assistantText: partial, - error: nil - ) - } - } - - /// Drop "No response requested." text blocks from the assistant message - /// at `idx`. If the message has no blocks left after the strip, remove - /// it entirely. Called at turn-finalization sites — the marker is the - /// model's response when a turn arrives without a user prompt - /// (ScheduleWakeup, hook re-entry) and reads as noise in the chat UI. - /// Strip CLI no-op meta text ("no response requested") from a message. - /// - /// `removeIfEmpty` controls whether a message left with no blocks is also - /// deleted. The normal stream path passes `true` to discard pure no-op - /// envelopes; the cancel path passes `false` so pausing a turn never makes - /// the partial assistant bubble disappear. - private static func stripNoOpText(at idx: Int, in messages: inout [ChatMessage], removeIfEmpty: Bool = true) { - guard messages.indices.contains(idx) else { return } - messages[idx].blocks.removeAll { block in - guard let text = block.text else { return false } - return CLIMetaEnvelope.isNoResponseRequested(text.trimmingCharacters(in: .whitespacesAndNewlines)) - } - if removeIfEmpty, messages[idx].blocks.isEmpty { - messages.remove(at: idx) - } - } - - /// Wrap a branch briefing into a system-prompt section the agent can use as - /// background context. The briefing is auto-generated from earlier threads, - /// so it is framed as advisory rather than authoritative. - private static func branchBriefingSystemPrompt(branch: String, briefing: String) -> String { - let trimmed = briefing.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - return """ - # Current branch briefing - - The notes below are an accumulated briefing of recent work on this \ - project's current branch (`\(branch)`). They are auto-generated from \ - previous chat threads — treat them as background context for the user's \ - request, and be aware they may be incomplete or slightly out of date. - - \(trimmed) - """ - } - - private func processStream( - streamId: UUID, - prompt: String, - cwd: String, - cliSessionId: String?, - internalSessionKey: String, - agentProvider: AgentProvider, - model: String?, - effort: String? = nil, - hookSettingsPath: String?, - permissionMode: PermissionMode = .default, - hookSessionMode: PermissionMode? = nil, - projectId: UUID, - window: WindowState - ) async { - // Mode used when registering a session with PermissionServer for hook auto-approve. - // When plan toggle is on, `permissionMode` is `.plan` (for the CLI flag) but the - // user's dropdown choice (e.g. `.auto`) should still drive the hook policy. - let registerMode = hookSessionMode ?? permissionMode - let streamStart = Date() - logger.info("[Stream:UI] starting processStream (cli=\(cliSessionId ?? "new"), key=\(internalSessionKey))") - - var sessionKey = internalSessionKey - - // Resolve per-backend send-request fields (MCP injection, ACP client - // spec, model split) before dispatching through the unified protocol. - var mcpClaudeConfigPath: String? = nil - var extraSystemPrompt: String? = nil - var mcpCodexOverrides: [String] = [] - var acpMCPServers: [JSONValue] = [] - var acpSpec: ACPClientSpec? = nil - var resolvedPrompt = prompt - var resolvedModel: String? = model - var resolvedSendMode: PermissionMode = permissionMode - var earlyStream: AsyncStream? = nil - - func appendExtraSystemPrompt(_ context: String) { - let trimmed = context.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - if let existing = extraSystemPrompt, !existing.isEmpty { - extraSystemPrompt = "\(existing)\n\n\(trimmed)" - } else { - extraSystemPrompt = trimmed - } - } - - let resolvedMemoryContext: String - if memoryEnabled, memoryInjectEnabled { - let hits = await memoryService.search(prompt, projectId: projectId, limit: memoryMaxContextItems) - resolvedMemoryContext = memoryContextSystemPrompt(for: hits) - } else { - resolvedMemoryContext = "" - } - - switch agentProvider { - case .claudeCode: - // Allocate a per-session IDE-MCP port so the Claude agent can call - // IDE-only tools — cross-project chat (`ide__send_to_thread`), - // thread history, running jobs, usage. The bridge is a perl - // one-liner Claude runs as the `rxcode-ide` MCP server child. - let idePort = await ideMCPServer.allocate( - sessionKey: sessionKey, - capabilities: AgentProvider.claudeCode.staticCapabilities - ) - let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } - mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge) - // Surface the accumulated briefing for the project's current branch - // to the agent as background context via `--append-system-prompt`. - if let branch = await GitHelper.currentBranch(at: cwd), - let briefing = threadStore.branchBriefingItem(projectId: projectId, branch: branch) { - extraSystemPrompt = Self.branchBriefingSystemPrompt( - branch: branch, - briefing: briefing.briefing - ) - } - appendExtraSystemPrompt(resolvedMemoryContext) - if let skillContext = await marketplace.promptContext(for: .claudeCode) { - appendExtraSystemPrompt(skillContext) - } - case .codex: - // Allocate a per-session IDE-MCP port so the Codex agent can call - // IDE-only tools — cross-project chat, thread history, running - // jobs, usage, durable memory. The bridge is a perl one-liner - // Codex runs as the `rxcode-ide` stdio MCP server child. - let idePort = await ideMCPServer.allocate( - sessionKey: sessionKey, - capabilities: AgentProvider.codex.staticCapabilities - ) - let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } - mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge) - mcpCodexOverrides += await marketplace.codexConfigOverrides() - resolvedPrompt = memoryContextPromptPrefix(for: resolvedMemoryContext, prompt: resolvedPrompt) - if let skillContext = await marketplace.promptContext(for: .codex) { - resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" - } - resolvedSendMode = registerMode - case .acp: - // Allocate a per-session IDE-MCP port so the ACP agent can call - // polyfill / introspection tools. The agent's MCP child is a - // perl one-liner that bridges its stdio to our TCP listener; - // the listener stays bound to this session for its lifetime. - let idePort = await ideMCPServer.allocate( - sessionKey: sessionKey, - capabilities: AgentProvider.acp.staticCapabilities - ) - let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } - acpMCPServers = await mcp.acpMCPServers( - projectPath: cwd, - bridgeCommand: bridge - ) - resolvedPrompt = memoryContextPromptPrefix(for: resolvedMemoryContext, prompt: resolvedPrompt) - if let skillContext = await marketplace.promptContext(for: .acp) { - resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" - } - // `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 - resolvedModel = split?.model ?? model - resolvedSendMode = registerMode - if let spec = acpClients.first(where: { $0.id == resolvedClientId && $0.enabled }) { - acpSpec = spec - } else { - logger.error("[ACP] no enabled client for id=\(resolvedClientId, privacy: .public)") - earlyStream = 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() - } - } - } - - let stream: AsyncStream - if let earlyStream { - stream = earlyStream - } else { - let request = BackendSendRequest( - streamId: streamId, - prompt: resolvedPrompt, - cwd: cwd, - sessionId: cliSessionId, - model: resolvedModel, - effort: effort, - permissionMode: resolvedSendMode, - planMode: permissionMode == .plan, - hookSettingsPath: hookSettingsPath, - mcpClaudeConfigPath: mcpClaudeConfigPath, - extraSystemPrompt: extraSystemPrompt, - mcpCodexOverrides: mcpCodexOverrides, - acpMCPServers: acpMCPServers, - acpSpec: acpSpec, - clientSessionKey: sessionKey - ) - stream = await backend(for: agentProvider).send(request) - } - - startFlushTimer(for: sessionKey) - - var eventCount = 0 - var lastEventTime = Date() - - do { - for await event in stream { - eventCount += 1 - let gap = Date().timeIntervalSince(lastEventTime) - lastEventTime = Date() - - guard !Task.isCancelled else { - logger.info("[Stream:UI] task cancelled after \(eventCount) events") - break - } - - let ownsSession = stateForSession(sessionKey).activeStreamId == streamId - - 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) - if sessionKey != resultEvent.sessionId { - if let state = sessionStates.removeValue(forKey: sessionKey) { - sessionStates[resultEvent.sessionId] = state - } - sessionIdRedirect[sessionKey] = resultEvent.sessionId - sessionKey = resultEvent.sessionId - } - let msgs = stateForSession(sessionKey).messages - if !msgs.isEmpty { - await saveSession(sessionId: resultEvent.sessionId, projectId: projectId, messages: msgs) - } - } else { - logger.debug("[Stream:UI] event #\(eventCount) — stream \(streamId) no longer owns session \(sessionKey), skipping") - } - continue - } - - switch event { - case .system(let systemEvent): - logger.info("[Stream:UI] event #\(eventCount) .system (gap=\(String(format: "%.1f", gap))s)") - if let model = systemEvent.model { - updateState(sessionKey) { $0.activeModelName = model } - } - // Hook events (SessionStart, PreToolUse, etc.) carry the parent's session_id, - // not this subprocess's. Acting on them flips currentSessionId mid-stream and - // triggers MessageListView's fade-out/in — visible as a blink. - let isHookEvent = systemEvent.subtype.hasPrefix("hook_") - if let sid = systemEvent.sessionId, !isHookEvent { - await permission.registerSession(sid: sid, projectKey: cwd, mode: registerMode) - // Capture the sessionKey BEFORE the reassignment so the - // reconciler can rename the previous row in place when - // the CLI advances `session_id` mid-stream. - let previousSessionKey = sessionKey - if sessionKey != sid { - if let state = sessionStates.removeValue(forKey: previousSessionKey) { - sessionStates[sid] = state - } - renameDraftState(from: previousSessionKey, to: sid, in: window) - sessionIdRedirect[previousSessionKey] = sid - sessionKey = sid - startFlushTimer(for: sid) - - // If this is the foreground session, also update window.currentSessionId. - // Do NOT treat `currentSessionId == nil` (the new-thread page) as foreground - // for an arbitrary streaming session — that caused the UI to auto-navigate - // to a previously-detached thread whenever its CLI advanced its session_id - // (e.g. pending→real on first system event, or compact_boundary swap). - let isFg = (window.currentSessionId ?? window.newSessionKey) == previousSessionKey - if isFg { window.currentSessionId = sid } - } - - let expectedPlaceholder = "pending-\(streamId.uuidString)" - if window.pendingPlaceholderIds.contains(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 - // genuinely-recent chats with an "in 0s" updatedAt. The - // first save once content arrives will refresh updatedAt. - let replacement = ChatSession( - id: sid, - projectId: old.projectId, - title: old.title, - 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, - 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) - threadStore.renameId(from: expectedPlaceholder, to: sid) - threadStore.upsert(replacement.summary, cliSessionId: sid) - window.removePendingPlaceholder(expectedPlaceholder) - } else { - if window.pendingPlaceholderIds.contains(expectedPlaceholder) { - window.removePendingPlaceholder(expectedPlaceholder) - allSessionSummaries.removeAll { $0.id == expectedPlaceholder } - threadStore.delete(id: expectedPlaceholder) - } - - // A retry reuses the same pending session key (oldKey) with a new streamId, - // 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) { - allSessionSummaries.removeAll { $0.id == oldKey } - threadStore.delete(id: oldKey) - window.removePendingPlaceholder(oldKey) - } - - // Decide whether to rename the previous row, insert a fresh - // one, or do nothing. Renaming in place is the load-bearing - // case: it stops empty "New Session" rows from accumulating - // every time the CLI advances `session_id` mid-stream (e.g. - // after a `compact_boundary`). - if let project = projects.first(where: { $0.id == projectId }) { - let msgs = stateForSession(sessionKey).messages - let firstUser = msgs.first(where: { $0.role == .user }) - let action = SessionRowReconciler.decide( - newSid: sid, - previousKey: previousSessionKey, - existingIds: Set(allSessionSummaries.map { $0.id }), - firstUserMessageContent: firstUser?.content - ) - switch action { - case .noop: - break - case .renameInPlace(let from, let to): - if let idx = allSessionSummaries.firstIndex(where: { $0.id == from }) { - let old = allSessionSummaries[idx] - let renamed = ChatSession.Summary( - id: to, - projectId: old.projectId, - title: old.title, - createdAt: old.createdAt, - updatedAt: old.updatedAt, - isPinned: old.isPinned, - agentProvider: old.agentProvider, - model: old.model, - effort: old.effort, - permissionMode: old.permissionMode, - origin: old.origin, - worktreePath: old.worktreePath, - worktreeBranch: old.worktreeBranch, - isArchived: old.isArchived, - archivedAt: old.archivedAt - ) - allSessionSummaries.remove(at: idx) - allSessionSummaries.removeAll { $0.id == to } - allSessionSummaries.insert(renamed, at: 0) - threadStore.renameId(from: from, to: to) - threadStore.upsert(renamed, cliSessionId: to) - } - case .insertNew(let id, let title): - // Use the user-message timestamp so the row doesn't - // reorder above more recent chats while still empty. - let firstUserDate = firstUser?.timestamp ?? Date() - let inserted = ChatSession.Summary( - id: id, - projectId: project.id, - title: title, - createdAt: firstUserDate, - updatedAt: firstUserDate, - isPinned: false, - agentProvider: agentProvider, - origin: agentProvider.defaultSessionOrigin - ) - allSessionSummaries.insert(inserted, at: 0) - threadStore.upsert(inserted, cliSessionId: id) - } - } - } - if previousSessionKey != sid { - broadcastMobileSessionRedirect(from: previousSessionKey, to: sid) - } - } - - if systemEvent.subtype == "compact_boundary" { - updateState(sessionKey) { state in - state.messages.append(ChatMessage(role: .assistant, content: "Previous conversation has been compacted", isCompactBoundary: true)) - } - } - - case .assistant(let assistantMessage): - logger.debug("[Stream:UI] event #\(eventCount) .assistant (gap=\(String(format: "%.1f", gap))s, blocks=\(assistantMessage.content.count))") - if assistantMessage.content.contains(where: { - if case .thinking = $0 { return true } - return false - }) { - updateState(sessionKey) { $0.isThinking = true } - } - // A turn can contain several model invocations (one per tool round-trip); - // each emits its own `usage.output_tokens` starting from zero. Track the - // running max per message id and sum across ids to get the turn total. - if let liveOutput = assistantMessage.usage?.outputTokens { - updateState(sessionKey) { state in - if let messageId = assistantMessage.id { - let existing = state.currentTurnOutputTokensByMessage[messageId] ?? 0 - state.currentTurnOutputTokensByMessage[messageId] = max(existing, liveOutput) - } else { - state.currentTurnOutputTokensUnkeyed = max(state.currentTurnOutputTokensUnkeyed, liveOutput) - } - } - if agentProvider == .codex { - let total = stateForSession(sessionKey).currentTurnOutputTokens - logger.info("[Stream:UI] Codex usage applied messageId=\(assistantMessage.id ?? "", privacy: .public) output=\(liveOutput) total=\(total)") - } - } - // ACP-style providers deliver fully-formed tool_use blocks inside .assistant - // events (no content_block_start raw stream). Commit any buffered text first - // so tool bubbles appear after — and not in the middle of — the prior text. - let hasToolUse = assistantMessage.content.contains { - if case .toolUse = $0 { return true } - return false - } - if hasToolUse { - flushPendingUpdates(for: sessionKey) - } - - updateState(sessionKey) { state in - // Text fallback: only buffer text when no text_delta has been received in - // this turn. Normally content_block_delta(text_delta) is the primary path. - let canBufferText: Bool = { - guard state.textDeltaBuffer.isEmpty else { return false } - let afterLastUser = (state.messages.lastIndex(where: { $0.role == .user }).map { $0 + 1 }) ?? 0 - return !state.messages.suffix(from: afterLastUser).contains { - $0.role == .assistant && $0.blocks.contains(where: \.isText) - } - }() - - for block in assistantMessage.content { - switch block { - case .text(let text): - if canBufferText, !text.isEmpty { - state.textDeltaBuffer += text - } - case .toolUse(let id, let name, let input): - state.isThinking = false - // Merge updates by id: ACP agents may re-emit the same toolUse - // with additional input (e.g. diff content arriving via a - // follow-up tool_call_update). Patch the existing block in - // place so the live edit info reaches `flushPendingUpdates` - // when the result lands. - if let existingMsgIdx = state.messages.indices.reversed().first(where: { - state.messages[$0].toolCallIndex(id: id) != nil - }), - let existingBlockIdx = state.messages[existingMsgIdx].toolCallIndex(id: id) { - var merged = state.messages[existingMsgIdx].blocks[existingBlockIdx].toolCall?.input ?? [:] - for (key, value) in input { merged[key] = value } - state.messages[existingMsgIdx].blocks[existingBlockIdx].toolCall?.input = merged - } else { - if state.needsNewMessage { - if let idx = state.messages.indices.reversed().first(where: { - state.messages[$0].role == .assistant && state.messages[$0].isStreaming - }) { - state.messages[idx].isStreaming = false - state.messages[idx].finalizeToolCalls() - Self.stripNoOpText(at: idx, in: &state.messages) - } - state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) - state.needsNewMessage = false - } else if state.messages.last?.role != .assistant - || !(state.messages.last?.isStreaming ?? false) { - state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) - } - if let lastIndex = state.messages.indices.last, - state.messages[lastIndex].role == .assistant { - state.messages[lastIndex].appendToolCall(ToolCall(id: id, name: name, input: input)) - } - } - case .thinking: - state.isThinking = true - } - } - } - - case .user(let userMessage): - logger.debug("[Stream:UI] event #\(eventCount) .user (gap=\(String(format: "%.1f", gap))s, toolUseId=\(userMessage.toolUseId ?? "none"))") - updateState(sessionKey) { state in - guard let toolUseId = userMessage.toolUseId else { return } - state.pendingToolResults.append((toolUseId, userMessage.content, userMessage.isError)) - state.needsNewMessage = true - } - - case .result(let resultEvent): - logger.info("[Stream:UI] event #\(eventCount) .result (gap=\(String(format: "%.1f", gap))s, isError=\(resultEvent.isError), session=\(resultEvent.sessionId))") - - // With `--input-format stream-json` the CLI stays alive waiting for more - // input. Close stdin on `result` so it exits cleanly, then finalize so - // any subagent children that survived the parent CLI get reaped. - await finalizeAgentStream(agentProvider: agentProvider, streamId: streamId) - - if sessionKey != resultEvent.sessionId { - if let state = sessionStates.removeValue(forKey: sessionKey) { - sessionStates[resultEvent.sessionId] = state - } - sessionKey = resultEvent.sessionId - } - - // A background completion is "finished, unread". Setting the - // flag inside finalizeStreamSession means the trailing - // `.streamingFinished` broadcast already carries it to mobile. - let isFg = (window.currentSessionId ?? window.newSessionKey) == sessionKey - let markUnread = !isFg && !resultEvent.isError - - finalizeStreamSession(for: sessionKey) { state in - if let cost = resultEvent.totalCostUsd { state.costUsd = cost } - if let duration = resultEvent.durationMs { state.durationMs += duration } - if let turns = resultEvent.totalTurns { state.turns += turns } - if let usage = resultEvent.usage { - state.inputTokens += usage.inputTokens - state.outputTokens += usage.outputTokens - state.cacheCreationTokens += usage.cacheCreationInputTokens - state.cacheReadTokens += usage.cacheReadInputTokens - } - if markUnread { state.hasUncheckedCompletion = true } - } - - recordStreamCompletion( - streamId: streamId, - sessionId: resultEvent.sessionId, - assistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), - error: resultEvent.isError ? "Agent reported an error result." : nil - ) - - if isFg { - window.currentSessionId = resultEvent.sessionId - if resultEvent.isError { - let errText = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) - ?? "\(agentProvider.displayName) returned an error." - addErrorMessage(errText, in: window) - } - } - - await saveSession( - sessionId: resultEvent.sessionId, - projectId: projectId, - messages: stateForSession(sessionKey).messages - ) - - if agentProvider == .claudeCode { - reconcileFromDisk(sessionId: resultEvent.sessionId, projectId: projectId, cwd: cwd) - } - - if !resultEvent.isError { - let sid = resultEvent.sessionId - let key = sessionKey - let cwdCapture = cwd - if agentProvider == .claudeCode { - Task { [weak self] in - guard let self else { return } - if let pct = await claude.fetchContextPercentage(sessionId: sid, cwd: cwdCapture) { - updateState(key) { $0.lastTurnContextUsedPercentage = pct } - } - } - } - - if notificationsEnabled { - let summary = allSessionSummaries.first(where: { $0.id == resultEvent.sessionId }) - let title = summary?.title ?? "New Session" - let responseText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) - let fallbackBody = responseNotificationFallback(from: responseText) - let pid = projectId - let sid = resultEvent.sessionId - let postLocalBanner = !NSApp.isActive - Task { [weak self] in - var body = fallbackBody - if let self, let summary { - body = await self.generateResponseNotificationSummary(responseText: responseText, summary: summary) ?? fallbackBody - } - await NotificationService.shared.postResponseComplete(title: title, body: body, projectId: pid, sessionId: sid, postLocalBanner: postLocalBanner) - } - } - - scheduleThreadSummaryUpdate( - sessionId: resultEvent.sessionId, - projectId: projectId, - cwd: cwd, - messages: stateForSession(sessionKey).messages - ) - scheduleMemoryExtraction( - sessionId: resultEvent.sessionId, - projectId: projectId, - messages: stateForSession(sessionKey).messages - ) - - // If this session is running in the background, automatically process any queued messages. - // Foreground sessions are handled by InputBarView via isStreaming onChange. - if !isFg { - await processBackgroundQueue(for: sessionKey, projectId: projectId, cwd: cwd, in: window) - } - } - - 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 - { - addErrorMessage("Rate limited. Retrying in \(Int(retry))s...", in: window) - } - - case .todoSnapshot(let snapshot): - let targetSession = snapshot.sessionId ?? sessionKey - let done = snapshot.items.filter { $0.status == .completed }.count - let active = snapshot.items.first(where: { $0.status == .inProgress })?.activeForm ?? "-" - logger.info( - "[TodoSnapshot] session=\(targetSession, privacy: .public) total=\(snapshot.items.count) done=\(done) active=\(active, privacy: .public)" - ) - threadStore.upsertTodoSnapshot(sessionId: targetSession, items: snapshot.items) - broadcastMobileSessionStatus(sessionID: targetSession) - - 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))") - } - handlePartialEvent(raw, for: sessionKey) - } - } - - let elapsed = Date().timeIntervalSince(streamStart) - logger.info("[Stream:UI] stream ended after \(eventCount) events, \(String(format: "%.1f", elapsed))s total") - - // Consume any remaining stderr — used as error message content below. - // If already consumed at result.isError time, this returns nil. - let stderrOutput = await consumeAgentStderr(agentProvider: agentProvider, streamId: streamId) - - if eventCount == 0 { - // User cancellation revokes activeStreamId or cancels the task — distinguish - // that from a real "CLI died with no output" failure. - let wasCancelled = Task.isCancelled || stateForSession(sessionKey).activeStreamId != streamId - if !wasCancelled { - let errorMsg = stderrOutput ?? "No response received" - addErrorMessage(errorMsg, in: window) - logger.error("[Stream:UI] no events received — appending error bubble. stderr=\(stderrOutput ?? "nil")") - } else { - logger.debug("[Stream:UI] no events received — suppressed (cancelled). stderr=\(stderrOutput ?? "nil")") - } - } - - let isStillOwner = stateForSession(sessionKey).activeStreamId == streamId - let stillStreaming = stateForSession(sessionKey).isStreaming - if stillStreaming, isStillOwner { - logger.warning("[Stream:UI] isStreaming was still true at stream end — forcing cleanup") - let markUnread = (window.currentSessionId ?? window.newSessionKey) != sessionKey - finalizeStreamSession(for: sessionKey) { state in - if markUnread { state.hasUncheckedCompletion = true } - } - - // If the last assistant message is invisible after cleanup (blocks=[] because - // all tool calls had empty/nil results), show an error bubble so the user - // understands what happened rather than seeing no response at all. - let lastMsg = stateForSession(sessionKey).messages.last - if lastMsg.map({ $0.role == .assistant && $0.blocks.isEmpty }) == true { - let errorMsg = stderrOutput ?? "Response was interrupted" - updateState(sessionKey) { state in - state.messages.append(ChatMessage(role: .assistant, content: errorMsg, isError: true)) - } - } - - let msgs = stateForSession(sessionKey).messages - if !msgs.isEmpty { - await saveSession(sessionId: sessionKey, projectId: projectId, messages: msgs) - } - } 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") - finalizeStreamSession(for: sessionKey) - let msgs = stateForSession(sessionKey).messages - if !msgs.isEmpty { - await saveSession(sessionId: sessionKey, projectId: projectId, messages: msgs) - } - } else { - logger.info("[Stream:UI] stream \(streamId) ended but newer stream \(currentOwner!) owns session — skipping cleanup") - } - } - - // Fallback completion record: covers cancellations, no-events errors, - // and any path where `.result` was not received. The `.result` case - // already records a completion before reaching here — recordStreamCompletion - // is idempotent (it overwrites with the latest), but if a prior call set a - // successful completion we don't want to clobber it with an error. - if pendingStreamCompletions[streamId] == nil { - let assistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) - let errorMsg: String? = eventCount == 0 - ? (stderrOutput ?? "Stream ended with no events.") - : (Task.isCancelled ? "Stream was cancelled." : nil) - recordStreamCompletion( - streamId: streamId, - sessionId: sessionKey, - assistantText: assistantText, - error: errorMsg - ) - } - } - } - - private func finalizeAgentStream(agentProvider: AgentProvider, streamId: UUID) async { - // Claude needs an explicit stdin close before finalize so the CLI - // sees EOF; other backends manage stdin internally. - if agentProvider == .claudeCode { - await claude.closeStdin(streamId: streamId) - } - await backend(for: agentProvider).finalize(streamId: streamId) - } - - /// Release the per-session IDE-MCP listener allocated for ACP turns. - /// Safe to call for non-ACP providers (no-op). - private func releaseIDESession(sessionKey: String) async { - await ideMCPServer.release(sessionKey: sessionKey) - } - - private func consumeAgentStderr(agentProvider: AgentProvider, streamId: UUID) async -> String? { - switch agentProvider { - case .claudeCode: - return await claude.consumeStderr(for: streamId) - case .codex: - return await codex.consumeStderr(for: streamId) - case .acp: - return await acp.consumeStderr(for: streamId) - } - } - - // MARK: - Text Delta Throttle (50ms) - - private func startFlushTimer(for sessionKey: String) { - stopFlushTimer(for: sessionKey) - let capturedKey = sessionKey - let task = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 50_000_000) - guard !Task.isCancelled else { break } - self?.flushPendingUpdates(for: capturedKey) - } - } - sessionStates[sessionKey, default: SessionStreamState()].flushTask = task - } - - private func stopFlushTimer(for sessionKey: String) { - sessionStates[sessionKey]?.flushTask?.cancel() - sessionStates[sessionKey]?.flushTask = nil - } - - private func flushPendingUpdates(for key: String) { - guard var state = sessionStates[key] else { return } - - let hasText = !state.textDeltaBuffer.isEmpty - let hasToolResults = !state.pendingToolResults.isEmpty - - guard hasText || hasToolResults else { return } - - let prevMessages = state.messages - - func lastAssistantIdx() -> Int? { - state.messages.indices.reversed().first { state.messages[$0].role == .assistant } - } - func lastStreamingAssistantIdx() -> Int? { - state.messages.indices.reversed().first { state.messages[$0].role == .assistant && state.messages[$0].isStreaming } - } - - // 1. Tool results — apply to the current streaming assistant message - if hasToolResults { - let results = state.pendingToolResults - state.pendingToolResults.removeAll(keepingCapacity: true) - if let idx = lastAssistantIdx() { - for (toolUseId, content, isError) in results { - let editPersistInfos: [(path: String, hunks: [PreviewFile.EditHunk], isWrite: Bool)] = { - guard !isError, - let blockIdx = state.messages[idx].toolCallIndex(id: toolUseId), - let call = state.messages[idx].blocks[blockIdx].toolCall, - ["edit", "multiedit", "multi_edit", "write"].contains(call.name.lowercased()) - else { return [] } - let claudeHunks = call.fileEditHunks - if !claudeHunks.isEmpty, let path = call.editedFilePath { - return [(path, claudeHunks, call.name.lowercased() == "write")] - } - // Codex `fileChange` shape: one tool call may touch multiple files. - let codexDiffs = call.fileChangeDiffs - guard !codexDiffs.isEmpty else { return [] } - return codexDiffs.map { ($0.path, [$0.hunk], false) } - }() - // Preserve the user-decision summary on ExitPlanMode. After the user - // accepts/rejects the plan, the CLI emits its own follow-up tool_result - // ("User has approved your plan…") which would overwrite "Accepted with …" - // and flip the plan card back to "pending" — re-showing the accept buttons - // on a card that was just decided. - let skipResultOverwrite: Bool = { - guard let blockIdx = state.messages[idx].toolCallIndex(id: toolUseId), - let call = state.messages[idx].blocks[blockIdx].toolCall, - Self.isExitPlanModeCall(call), - let existing = call.result else { return false } - return Self.planDecisionResultPrefixes.contains { existing.hasPrefix($0) } - }() - if !skipResultOverwrite { - state.messages[idx].setToolResult(id: toolUseId, result: content, isError: isError) - } - if !editPersistInfos.isEmpty { - for info in editPersistInfos { - threadStore.appendFileEdit( - sessionId: key, - path: info.path, - hunks: info.hunks, - containsWrite: info.isWrite - ) - } - threadFileEditsRevision &+= 1 - } - } - } - } - - // 2. Text delta flush - if hasText { - let buffered = state.textDeltaBuffer - state.textDeltaBuffer = "" - if let idx = lastStreamingAssistantIdx() { - if state.needsNewMessage { - // New Claude turn after receiving tool result — start a new ChatMessage - state.messages[idx].isStreaming = false - state.messages[idx].finalizeToolCalls() - Self.stripNoOpText(at: idx, in: &state.messages) - state.needsNewMessage = false - state.messages.append(ChatMessage(role: .assistant, content: buffered, isStreaming: true)) - } else { - state.messages[idx].appendText(buffered) - } - } else { - state.messages.append(ChatMessage(role: .assistant, content: buffered, isStreaming: true)) - } - } - - sessionStates[key] = state - broadcastMobileMessageDiff(sessionKey: key, prev: prevMessages, next: state.messages, isStreaming: state.isStreaming) - } - - // MARK: - Stream Event Handler - - private func handlePartialEvent(_ raw: String, for sessionKey: String) { - guard let data = raw.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - - let event: [String: Any] - if let type = json["type"] as? String, type == "stream_event", - let nested = json["event"] as? [String: Any] - { - event = nested - } else { - event = json - } - - guard let eventType = event["type"] as? String else { return } - - switch eventType { - case "content_block_start": - guard let contentBlock = event["content_block"] as? [String: Any], - let blockType = contentBlock["type"] as? String else { return } - - if blockType == "tool_use" { - guard let id = contentBlock["id"] as? String, - let name = contentBlock["name"] as? String else { return } - let toolCall = ToolCall(id: id, name: name, input: [:]) - // Flush the text buffer first so text blocks are committed before tools - flushPendingUpdates(for: sessionKey) - updateState(sessionKey) { state in - state.isThinking = false - // needsNewMessage: new Claude turn after tool result — create a new ChatMessage - if state.needsNewMessage { - if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant && state.messages[$0].isStreaming }) { - state.messages[idx].isStreaming = false - state.messages[idx].finalizeToolCalls() - Self.stripNoOpText(at: idx, in: &state.messages) - } - state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) - state.needsNewMessage = false - } else if state.messages.last?.role != .assistant || !(state.messages.last?.isStreaming ?? false) { - state.messages.append(ChatMessage(role: .assistant, isStreaming: true)) - } - if let lastIndex = state.messages.indices.last, - state.messages[lastIndex].role == .assistant - { - state.messages[lastIndex].appendToolCall(toolCall) - } - // Ready to receive input_json_delta - state.activeToolId = id - state.activeToolInputBuffer = "" - } - } else if blockType == "text" { - // New text block started — if needsNewMessage, prepare a new ChatMessage - updateState(sessionKey) { state in - if state.needsNewMessage { - // Keep the flag so a new message is created on the next text_delta flush - // (needsNewMessage is handled inside flush) - } - state.isThinking = false - state.activeToolId = nil - state.activeToolInputBuffer = "" - } - } else if blockType == "thinking" { - updateState(sessionKey) { $0.isThinking = true } - } - - case "content_block_delta": - guard let delta = event["delta"] as? [String: Any], - let deltaType = delta["type"] as? String else { return } - - if deltaType == "text_delta", let text = delta["text"] as? String { - updateState(sessionKey) { state in - state.isThinking = false - state.textDeltaBuffer += text - } - } else if deltaType == "input_json_delta", let partial = delta["partial_json"] as? String { - updateState(sessionKey) { state in - state.activeToolInputBuffer += partial - } - } else if deltaType == "thinking_delta" { - updateState(sessionKey) { $0.isThinking = true } - } - - case "content_block_stop": - // Finalize tool_use input — parse the accumulated JSON and apply to the tool call - updateState(sessionKey) { state in - guard let toolId = state.activeToolId, !state.activeToolInputBuffer.isEmpty else { - state.activeToolId = nil - return - } - let buffer = state.activeToolInputBuffer - state.activeToolId = nil - state.activeToolInputBuffer = "" - - guard let inputData = buffer.data(using: .utf8), - 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) - { - state.messages[msgIdx].blocks[blockIdx].toolCall?.input = parsed - if let toolName = state.messages[msgIdx].blocks[blockIdx].toolCall?.name, - 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 ?? "-" - logger.info( - "[TodoWrite] session=\(sessionKey, privacy: .public) total=\(todos.count) done=\(done) active=\(active, privacy: .public)" - ) - threadStore.upsertTodoSnapshot(sessionId: sessionKey, items: todos) - } - } - } - - default: - break - } - } - - // MARK: - Cancel - - private func detachCurrentStream(in window: WindowState) { - let key = window.currentSessionId ?? window.newSessionKey - flushPendingUpdates(for: key) - stopFlushTimer(for: key) - } - - func cancelStreaming(in window: WindowState) async { - let key = window.currentSessionId ?? window.newSessionKey - let streamToCancel = sessionStates[key]?.activeStreamId - sessionStates[key]?.streamTask?.cancel() - - // Finalize the in-progress turn *before* the await below, in a single - // state mutation. The session `isStreaming` flag and the streaming - // message's own `isStreaming` flag must flip to false together: if they - // disagree when the message list rebuilds (which is driven by the - // session flag), the paused bubble lands in neither the settled list - // nor the streaming view and vanishes until the next turn. Clearing - // isStreaming here also stops processStream's end-of-stream cleanup - // from re-finalizing the cancelled message while we suspend. - flushPendingUpdates(for: key) - stopFlushTimer(for: key) - - updateState(key) { state in - state.isStreaming = false - state.isThinking = false - state.needsNewMessage = false - state.activeStreamId = nil - state.streamTask = nil - state.activeToolId = nil - state.activeToolInputBuffer = "" - state.textDeltaBuffer = "" - state.pendingToolResults.removeAll() - if let idx = state.messages.indices.reversed().first(where: { - state.messages[$0].role == .assistant && state.messages[$0].isStreaming - }) { - // The user paused this turn — keep the partial assistant bubble - // visible. markStreamInterrupted() clears the message's streaming - // flag and retains in-progress tool calls (flagged as interrupted) - // instead of dropping them; the no-op strip below is told not to - // delete an emptied message. - state.messages[idx].markStreamInterrupted() - if let start = state.streamingStartDate { - state.messages[idx].duration = Date().timeIntervalSince(start) - } - Self.stripNoOpText(at: idx, in: &state.messages, removeIfEmpty: false) - } - state.streamingStartDate = nil - state.inFlightUserAttachments = [] - } - - window.showError = false - window.errorMessage = nil - - if let streamToCancel { - let provider = sessionStates[key]?.agentProvider ?? effectiveModelSelection(in: window).provider - await backend(for: provider).cancel(streamId: streamToCancel) - } - - // Save messages accumulated up to the point of cancellation to disk (prevent data loss). - // The placeholder session (if any) is left in place so partial messages remain visible; - // it will be promoted to the real CLI session id on the next user turn. - if let project = window.selectedProject { - let messages = stateForSession(key).messages - if !messages.isEmpty { - await saveSession(sessionId: key, projectId: project.id, messages: messages) - } - } - } - - private func recordStreamingDuration(for key: String) { - guard let start = sessionStates[key]?.streamingStartDate else { return } - let duration = Date().timeIntervalSince(start) - updateState(key) { state in - state.streamingStartDate = nil - if let idx = state.messages.indices.reversed().first(where: { state.messages[$0].role == .assistant }) { - state.messages[idx].duration = duration - } - } - } - - // MARK: - Permission Response - - func respondToPermission(_ request: PermissionRequest, decision: PermissionDecision, in window: WindowState) async { - await permission.respond(toolUseId: request.id, decision: decision) - window.pendingPermissions.removeAll { $0.id == request.id } - mobilePendingRequests.removeValue(forKey: request.id) - if let sessionId = request.sessionId { - broadcastMobileSessionStatus(sessionID: sessionId) - } - } - - // MARK: - AskUserQuestion Response - - /// Deliver the user's answers for an AskUserQuestion tool call via the PreToolUse hook. - /// - /// AskUserQuestion is handled like any other PreToolUse hook: the PermissionServer is - /// holding the HTTP connection open waiting for a decision. We resolve it with `allow` + - /// `updatedInput: {questions, answers: {questionText: }}` so the CLI injects - /// the answers into the tool input and proceeds. - func respondToAskUserQuestion( - toolUseId: String, - answers: [Int: AskUserQuestion.Answer], - in window: WindowState - ) async { - let key = window.currentSessionId ?? window.newSessionKey - - var updatedInput = JSONValue.object([ - "questions": .array([]), - "answers": .object([:]), - ]) - - updateState(key) { state in - for i in state.messages.indices.reversed() { - guard let idx = state.messages[i].toolCallIndex(id: toolUseId), - let toolInput = state.messages[i].blocks[idx].toolCall?.input, - let parsed = AskUserQuestion(input: toolInput) else { continue } - - updatedInput = AskUserQuestion.updatedInputJSON( - originalInput: toolInput, - questions: parsed.questions, - answers: answers - ) - let summary = AskUserQuestion.summary(questions: parsed.questions, answers: answers) - state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) - return - } - } - - window.pendingPermissions.removeAll { $0.id == toolUseId } - let requestSessionId = mobilePendingRequests.removeValue(forKey: toolUseId)?.sessionId - if window.presentedPermissionId == toolUseId { - window.presentedPermissionId = nil - } - if let requestSessionId { - broadcastMobileSessionStatus(sessionID: requestSessionId) - } - broadcastMobileQuestionQueue() - - await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) - } - - /// User dismissed the question sheet without answering — resolve the hook as deny so - /// the CLI does not block, and clear the pending entry from the window queue. - func skipAskUserQuestion(toolUseId: String, in window: WindowState) async { - window.pendingPermissions.removeAll { $0.id == toolUseId } - let requestSessionId = mobilePendingRequests.removeValue(forKey: toolUseId)?.sessionId - if window.presentedPermissionId == toolUseId { - window.presentedPermissionId = nil - } - if let requestSessionId { - broadcastMobileSessionStatus(sessionID: requestSessionId) - } - broadcastMobileQuestionQueue() - await permission.respond(toolUseId: toolUseId, decision: .deny) - } - - /// Apply a question answer that arrived from a paired mobile device. - /// Resolves the CLI hook, mirrors the answer into chat history, and clears - /// the request from every desktop window's queue. An empty `answers` array - /// means the user chose "Skip All Questions" on mobile. - private func handleMobileQuestionAnswer(_ payload: QuestionAnswerPayload) async { - let toolUseId = payload.toolUseID - guard let request = mobilePendingRequests[toolUseId] else { return } - - guard !payload.answers.isEmpty, let parsed = AskUserQuestion(input: request.toolInput) else { - // Skip — or a malformed payload we cannot answer: deny the hook. - clearPendingQuestion(toolUseId: toolUseId, sessionId: request.sessionId) - await permission.respond(toolUseId: toolUseId, decision: .deny) - return - } - - var answers: [Int: AskUserQuestion.Answer] = [:] - for entry in payload.answers { - answers[entry.questionIndex] = entry.multiSelect - ? .multi(entry.values) - : .single(entry.values.first ?? "") - } - - let updatedInput = AskUserQuestion.updatedInputJSON( - originalInput: request.toolInput, - questions: parsed.questions, - answers: answers - ) - let summary = AskUserQuestion.summary(questions: parsed.questions, answers: answers) - - if let sessionId = request.sessionId { - updateState(sessionId) { state in - for i in state.messages.indices.reversed() { - guard state.messages[i].toolCallIndex(id: toolUseId) != nil else { continue } - state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) - return - } - } - } - - clearPendingQuestion(toolUseId: toolUseId, sessionId: request.sessionId) - await permission.respondAskUserQuestion(toolUseId: toolUseId, updatedInput: updatedInput) - } - - /// Apply a plan decision that arrived from a paired mobile device. Builds a - /// transient `WindowState` bound to the target session — mirroring - /// `handleMobileUserMessage` — and delegates to `respondToPlanDecision`, - /// which owns all the desktop plan logic (CLI hook release, decision summary - /// into `toolCall.result` + `planDecisionSummaries`, `threadStore` persist, - /// one-shot plan-mode clear, follow-up permission mode, continuation prompt). - /// The updated messages broadcast back through the normal session-update sync. - private func handleMobilePlanDecision(_ payload: PlanDecisionPayload) async { - let sessionID = resolveCurrentSessionId(payload.sessionID) - guard let summary = allSessionSummaries.first(where: { $0.id == sessionID }) else { - logger.error("[MobileSync] plan decision for unknown thread=\(payload.sessionID, privacy: .public)") - return - } - let window = WindowState() - window.selectedProject = projects.first(where: { $0.id == summary.projectId }) - window.currentSessionId = sessionID - // `respondToPlanDecision` reads `window.sessionPlanMode` to clear plan - // mode one-shot and `window.sessionPermissionMode` for re-registration — - // mirror the live session state into the fresh window. - if let state = sessionStates[sessionID] { - window.sessionPlanMode = state.planMode - window.sessionPermissionMode = state.permissionMode - } - await respondToPlanDecision( - toolUseId: payload.toolUseID, - action: payload.toDecisionAction(), - in: window - ) - } - - /// Remove a resolved `AskUserQuestion` request from every window's queue and - /// re-broadcast the (now smaller) queue to mobile. - private func clearPendingQuestion(toolUseId: String, sessionId: String?) { - for window in registeredWindows() { - window.pendingPermissions.removeAll { $0.id == toolUseId } - if window.presentedPermissionId == toolUseId { - window.presentedPermissionId = nil - } - } - mobilePendingRequests.removeValue(forKey: toolUseId) - if let sessionId { - broadcastMobileSessionStatus(sessionID: sessionId) - } - broadcastMobileQuestionQueue() - } - - // MARK: - Plan Decision Response - - /// Resolve a Claude `ExitPlanMode` tool call based on the user's choice in the plan card. - /// Drives both the PermissionServer hook response (allow/deny + optional reason or - /// follow-up mode change) and the local UI bookkeeping (clear plan-mode pill, update - /// permission chip, mark the tool block decided). - func respondToPlanDecision(toolUseId: String, action: PlanDecisionAction, in window: WindowState) async { - let summary: String - let decision: PermissionDecision - let nextMode: PermissionMode? - - switch action { - case .acceptAsk: - summary = "Accepted with Ask" - decision = .allowAndSetMode(newMode: .default) - nextMode = .default - case .acceptWithEdits: - summary = "Accepted with Edits" - decision = .allowAndSetMode(newMode: .acceptEdits) - nextMode = .acceptEdits - case .acceptAutoApprove: - summary = "Accepted with Auto-approve" - decision = .allowAndSetMode(newMode: .auto) - nextMode = .auto - case .rejectWithFeedback(let reason): - let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) - summary = trimmed.isEmpty ? "Rejected" : "Rejected: \(trimmed)" - decision = .denyWithReason(reason: trimmed.isEmpty ? "User rejected the plan." : trimmed) - nextMode = nil - case .reject: - summary = "Rejected" - decision = .denyWithReason(reason: "User rejected the plan.") - nextMode = nil - } - - // Record the outcome on the tool block so `PlanCardView` flips from buttons to - // a "decided" status row. This mirrors how AskUserQuestionView reads `toolCall.result`. - // Also write into a sidecar dict that survives CLI-backed session reloads — - // the CLI emits its own follow-up tool_result ("User has approved your plan…") - // that overwrites `ToolCall.result` once the session jsonl is parsed fresh - // from disk, so the in-memory result alone is not reliable. - let key = window.currentSessionId ?? window.newSessionKey - updateState(key) { state in - for i in state.messages.indices.reversed() { - if state.messages[i].toolCallIndex(id: toolUseId) != nil { - state.messages[i].setToolResult(id: toolUseId, result: summary, isError: false) - break - } - } - state.planDecisionSummaries[toolUseId] = summary - } - threadStore.setPlanDecision(sessionId: key, toolCallId: toolUseId, summary: summary) - - // Plan-mode is one-shot — clear the pill so the next user turn isn't in plan mode. - // This also triggers a permission re-register (no-op if there's no live CLI sid). - if window.sessionPlanMode { - window.sessionPlanMode = false - updateState(key) { $0.planMode = false } - } - - // Persist the new permission mode in the dropdown when the user opted for a - // follow-up mode change. `setSessionPermissionMode` also re-registers with the - // PermissionServer for the live session. - if let nextMode { - setSessionPermissionMode(nextMode, in: window) - } else { - // Even when no mode change is requested, re-register so the server registry - // reflects the now-cleared plan-mode boolean. - reregisterPermissionMode(in: window) - } - - await permission.respond(toolUseId: toolUseId, decision: decision) - - // When the CLI honors `allowAndSetMode` it continues the same turn — the model - // executes (or revises) the plan inline and the turn ends naturally. In that - // case sending a follow-up prompt would spawn a redundant second turn that - // reports "the work is already done". Only inject a continuation message when - // the turn actually ended without producing any post-plan content, mirroring - // the older CLI behavior where ExitPlanMode terminated the turn outright. - let continuationPrompt = Self.continuationPrompt(for: action) - - if let continuationPrompt { - if let task = sessionStates[key]?.streamTask { - _ = await task.value - } - if turnContinuedAfterPlan(toolUseId: toolUseId, sessionKey: key) { - return - } - await sendPrompt( - continuationPrompt, - skipAppendingUserMessage: true, - in: window - ) - } - } - - /// 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 planBlockIdx = messages[messageIdx].toolCallIndex(id: toolUseId) else { - continue - } - // 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 - } - - /// Prefixes of result strings written by `respondToPlanDecision`. Sourced from - /// `PlanDecisionAction.userDecisionResultPrefixes` so the chip in chat, the - /// CLI-session reload guard, and the live-stream guard all share one source - /// of truth. - static let planDecisionResultPrefixes: [String] = PlanDecisionAction.userDecisionResultPrefixes - - static func isExitPlanModeCall(_ call: ToolCall) -> Bool { - let n = call.name.lowercased() - return n == "exitplanmode" || n == "exit_plan_mode" - } - - /// Hidden follow-up prompt to send after a plan decision, or nil if the chat - /// should stop. Plain `.reject` returns nil; `.rejectWithFeedback` with empty - /// feedback also returns nil (the user effectively did a plain reject). - static func continuationPrompt(for action: PlanDecisionAction) -> String? { - switch action { - case .acceptAsk, .acceptWithEdits, .acceptAutoApprove: - return "Proceed with the plan." - case .rejectWithFeedback(let reason): - let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty - ? nil - : "Revise the plan based on this feedback: \(trimmed)" - case .reject: - return nil - } - } - - // MARK: - Project Management - - func addProject(name: String, path: String, gitHubRepo: String?) async { - guard !projects.contains(where: { $0.path == path }) else { return } - let project = Project(name: name, path: path, gitHubRepo: gitHubRepo) - projects.append(project) - do { - try await persistence.saveProjects(projects) - } catch { - logger.error("Failed to save projects: \(error.localizedDescription)") - } - } - - func selectProject(_ project: Project, in window: WindowState) { - guard window.selectedProject?.id != project.id else { return } - - saveDraft(in: window) - saveQueue(in: window) - - if isStreaming(in: window) { - detachCurrentStream(in: window) - } - - if let currentId = window.currentSessionId, - let currentProject = window.selectedProject, - let state = sessionStates[currentId], - !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.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, - worktreePath: summary?.worktreePath, - worktreeBranch: summary?.worktreeBranch, - isArchived: summary?.isArchived ?? false, - archivedAt: summary?.archivedAt - ) - Task { - do { try await self.persistence.saveSession(session) } - catch { self.logger.error("Failed to save current session before project switch: \(error.localizedDescription)") } - } - } - - // animation: nil — all mutations land in the same frame; sessionStates.filter fires - // one @Observable notification instead of N removeValue calls. - withAnimation(nil) { - window.showingBriefing = false - window.selectedProject = project - sessionStates = sessionStates.filter { $0.value.isStreaming } - resetToNewChat(in: window) - } - - activeProjectPath = project.path - Task { await refreshMCPServers() } - UserDefaults.standard.set(project.id.uuidString, forKey: "selectedProjectId") - } - - func addProjectFromFolder(_ url: URL, in window: WindowState) async { - let isGitRepo = FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path) - let gitHubRepo = isGitRepo ? detectGitHubOwnerRepo(at: url.path) : nil - await addAndSelectProject(name: url.lastPathComponent, path: url.path, gitHubRepo: gitHubRepo, in: window) - } - - private func addAndSelectProject(name: String, path: String, gitHubRepo: String? = nil, in window: WindowState) async { - if let existing = projects.first(where: { $0.path == path }) { - selectProject(existing, in: window) - return - } - await addProject(name: name, path: path, gitHubRepo: gitHubRepo) - if let project = projects.last { - selectProject(project, in: window) - } - } - - // MARK: - Session Management - - private func switchToSession(_ session: ChatSession, messages loadedMessages: [ChatMessage]? = nil, in window: WindowState) { - let existingState = sessionStates[session.id] - logger.info("[SwitchToSession] sid=\(session.id, privacy: .public) hasState=\(existingState != nil) existingMessages=\(existingState?.messages.count ?? -1) existingIsStreaming=\(existingState?.isStreaming ?? false) preloadedMessages=\(loadedMessages?.count ?? -1)") - saveDraft(in: window) - saveQueue(in: window) - - if isStreaming(in: window) { - detachCurrentStream(in: window) - } - - let outgoingId = window.currentSessionId - - if sessionStates[session.id] == nil { - var state = SessionStreamState() - state.agentProvider = session.agentProvider - state.model = session.model - state.effort = session.effort - state.permissionMode = session.permissionMode - if let msgs = loadedMessages { - state.messages = cleanLoadedMessages(msgs) - state.planDecisionSummaries = threadStore.loadPlanDecisions(sessionId: session.id) - sessionStates[session.id] = state - logger.info("[SwitchToSession] applied preloaded messages sid=\(session.id, privacy: .public) cleaned=\(state.messages.count)") - } else { - // Switch with an empty state first; actual messages are loaded in the background and injected later - state.isLoadingFromDisk = true - sessionStates[session.id] = state - if let project = window.selectedProject { - logger.info("[SwitchToSession] background load triggered sid=\(session.id, privacy: .public) cwd=\(project.path, privacy: .public)") - loadMessagesInBackground(projectId: project.id, sessionId: session.id, cwd: project.path) - } else { - logger.error("[SwitchToSession] no selectedProject — cannot load messages sid=\(session.id, privacy: .public)") - } - } - } else if sessionStates[session.id]?.messages.isEmpty == true, - sessionStates[session.id]?.isStreaming != true, - 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 } - if state.effort == nil { state.effort = session.effort } - if state.permissionMode == nil { state.permissionMode = session.permissionMode } - state.isLoadingFromDisk = true - sessionStates[session.id] = state - } - logger.info("[SwitchToSession] re-loading empty cached state sid=\(session.id, privacy: .public) cwd=\(project.path, privacy: .public)") - loadMessagesInBackground(projectId: project.id, sessionId: session.id, cwd: project.path) - } else { - logger.info("[SwitchToSession] reusing cached state sid=\(session.id, privacy: .public) messages=\(existingState?.messages.count ?? -1) isStreaming=\(existingState?.isStreaming ?? false)") - } - - if sessionStates[session.id]?.isStreaming == true { - flushPendingUpdates(for: session.id) - } - - updateState(session.id) { $0.hasUncheckedCompletion = false } - - window.showingBriefing = false - window.pendingWorktreePath = nil - window.pendingWorktreeBranch = nil - window.currentSessionId = session.id - window.sessionAgentProvider = sessionStates[session.id]?.agentProvider ?? session.agentProvider - window.sessionModel = sessionStates[session.id]?.model ?? session.model - window.sessionEffort = sessionStates[session.id]?.effort ?? session.effort - window.sessionPermissionMode = sessionStates[session.id]?.permissionMode ?? session.permissionMode - window.sessionPlanMode = sessionStates[session.id]?.planMode ?? false - window.inputText = window.draftTexts[session.id] ?? "" - window.messageQueue = window.draftQueues[session.id] ?? [] - - releaseOutgoingSession(outgoingId, excluding: session.id, in: window) - - if sessionStates[session.id]?.isStreaming == true { - startFlushTimer(for: session.id) - } - } - - private func releaseOutgoingSession(_ outgoingId: String?, excluding newId: String? = nil, in window: WindowState) { - guard let outgoingId, - outgoingId != newId, - !(sessionStates[outgoingId]?.isStreaming ?? false) else { return } - let outgoingMessages = sessionStates[outgoingId]?.messages ?? [] - Task { [weak self] in - guard let self else { return } - if !outgoingMessages.isEmpty, let project = window.selectedProject { - let summary = allSessionSummaries.first(where: { $0.id == outgoingId }) - let title = summary?.title ?? "Session" - let state = sessionStates[outgoingId] - 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, - 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)") } - } - if window.currentSessionId != outgoingId { - sessionStates.removeValue(forKey: outgoingId) - } - } - } - - private func didSwitchToSession(_ session: ChatSession) async { - if let index = projects.firstIndex(where: { $0.id == session.projectId }) { - projects[index].lastSessionId = session.id - do { - try await persistence.saveProjects(projects) - } catch { - logger.error("Failed to save projects: \(error.localizedDescription)") - } - } - } - - func resumeSession(_ session: ChatSession, in window: WindowState) async { - switchToSession(session, in: window) - await didSwitchToSession(session) - } - - // MARK: - GitHub - - func loginToGitHub() async throws -> DeviceCodeResponse { - try await github.startDeviceFlow() - } - - func completeGitHubLogin(deviceCode: String, interval: Int) async throws { - _ = try await github.pollForToken(deviceCode: deviceCode, interval: interval) - - let user = try await github.fetchUser() - gitHubUser = user - isLoggedIn = true - onboardingCompleted = true - UserDefaults.standard.set(true, forKey: "onboardingCompleted") - - do { try await persistence.saveGitHubUser(user) } - catch { logger.error("Failed to cache GitHub user: \(error.localizedDescription)") } - - do { - let publicKey = try await github.setupSSH() - try await github.registerSSHKey(publicKey) - } catch { - logger.warning("SSH setup failed: \(error.localizedDescription)") - } - } - - func skipGitHubLogin() { - onboardingCompleted = true - UserDefaults.standard.set(true, forKey: "onboardingCompleted") - } - - var isFetchingRepos = false - - func fetchRepos() async { - isFetchingRepos = true - defer { isFetchingRepos = false } - do { repos = try await github.fetchRepos() } - catch { logger.error("Failed to fetch repos: \(error.localizedDescription)") } - } - - func cloneAndAddProject(_ repo: GitHubRepo, in window: WindowState) async throws { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let clonePath = "\(home)/RxCode/\(repo.name)" - let parentDir = "\(home)/RxCode" - let fm = FileManager.default - if !fm.fileExists(atPath: parentDir) { - try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true) - } - try await github.cloneRepo(repo, to: clonePath) - await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: repo.fullName, in: window) - } - - func loadCustomRepos() async { - customRepos = await persistence.loadCustomRepos() - } - - func addCustomRepo(url: String, name: String, in window: WindowState) async throws { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let clonePath = "\(home)/RxCode/\(name)" - let fm = FileManager.default - if !fm.fileExists(atPath: "\(home)/RxCode") { - try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) - } - if fm.fileExists(atPath: clonePath) { - throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(name)' already exists in ~/RxCode"]) - } - try await github.cloneRepo(from: url, to: clonePath) - let repo = CustomRepo(name: name, cloneURL: url) - customRepos.append(repo) - try await persistence.saveCustomRepos(customRepos) - await addAndSelectProject(name: name, path: clonePath, gitHubRepo: nil, in: window) - } - - func cloneCustomRepo(_ repo: CustomRepo, in window: WindowState) async throws { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let clonePath = "\(home)/RxCode/\(repo.name)" - let fm = FileManager.default - if !fm.fileExists(atPath: "\(home)/RxCode") { - try fm.createDirectory(atPath: "\(home)/RxCode", withIntermediateDirectories: true) - } - if fm.fileExists(atPath: clonePath) { - throw NSError(domain: "RxCode", code: 1, userInfo: [NSLocalizedDescriptionKey: "A folder named '\(repo.name)' already exists in ~/RxCode"]) - } - try await github.cloneRepo(from: repo.cloneURL, to: clonePath) - await addAndSelectProject(name: repo.name, path: clonePath, gitHubRepo: nil, in: window) - } - - func removeCustomRepo(_ repo: CustomRepo) async { - customRepos.removeAll { $0.id == repo.id } - do { - try await persistence.saveCustomRepos(customRepos) - } catch { - logger.error("Failed to save custom repos: \(error.localizedDescription)") - } - } - - // MARK: - View Convenience API - - func startNewChat(in window: WindowState) { - if isStreaming(in: window) { detachCurrentStream(in: window) } - saveDraft(in: window) - saveQueue(in: window) - releaseOutgoingSession(window.currentSessionId, in: window) - resetToNewChat(in: window) - } - - private func resetToNewChat(in window: WindowState) { - window.showingBriefing = false - window.currentSessionId = nil - window.sessionAgentProvider = nil - window.sessionModel = nil - window.sessionEffort = nil - window.sessionPermissionMode = nil - window.sessionPlanMode = false - window.pendingWorktreePath = nil - window.pendingWorktreeBranch = nil - sessionStates.removeValue(forKey: window.newSessionKey) - window.inputText = window.draftTexts[newDraftKey(for: window)] ?? "" - window.messageQueue = window.draftQueues[newDraftKey(for: window)] ?? [] - window.requestInputFocus = true - } - - func renameSession(_ session: ChatSession, to newTitle: String) async { - if let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) { - allSessionSummaries[si].title = newTitle - threadStore.upsert(allSessionSummaries[si]) - } - await updateSessionMetadata(session, persistTitle: true) { $0.title = newTitle } - broadcastMobileSessionStatus(sessionID: session.id) - } - - /// True if `currentTitle` looks like our auto-derived placeholder (matches what - /// `ChatSession.placeholderTitle(from:)` would produce). Used to decide whether - /// to overwrite with an LLM-generated title — never overwrite a user's manual rename. - private func isAutoGeneratedTitle(_ currentTitle: String, firstUserMessage: String) -> Bool { - currentTitle == ChatSession.defaultTitle - || currentTitle == ChatSession.placeholderTitle(from: firstUserMessage) - || currentTitle == "New session" - || currentTitle.isEmpty - } - - /// Follow `sessionIdRedirect` chains to the current sid. The CLI may swap - /// `pending-` → real sid (and later advance the sid again on - /// `compact_boundary`) while a long-running task holds the old id. - private func resolveCurrentSessionId(_ id: String) -> String { - var current = id - var seen: Set = [current] - while let next = sessionIdRedirect[current] { - if seen.contains(next) { break } - seen.insert(next) - current = next - } - return current - } - - /// Spawn a one-shot summarization call to generate a 3–6 word title for the given - /// session, then persist it via `renameSession` if the title is still the placeholder. - /// No-op if the session was already renamed manually or the LLM call fails. - func maybeGenerateLLMTitle(for sessionId: String) async { - let resolved = resolveCurrentSessionId(sessionId) - guard let summary = allSessionSummaries.first(where: { $0.id == resolved }) else { - logger.warning("Title generation skipped: no summary for \(resolved) (original \(sessionId))") - return - } - let messages = sessionStates[resolved]?.messages ?? [] - let firstUserRaw = messages.first(where: { $0.role == .user })?.content ?? "" - let firstUser = ChatSession.stripAttachmentMarkers(from: firstUserRaw) - guard !firstUser.isEmpty else { return } - guard isAutoGeneratedTitle(summary.title, firstUserMessage: firstUserRaw) else { return } - guard let title = await generateSessionTitle(firstUserMessage: firstUser, summary: summary) else { return } - // Re-resolve after the LLM call — the id may have been swapped while we waited. - let currentId = resolveCurrentSessionId(sessionId) - guard let stillPlaceholder = allSessionSummaries.first(where: { $0.id == currentId }), - isAutoGeneratedTitle(stillPlaceholder.title, firstUserMessage: firstUser) else { return } - guard let project = projects.first(where: { $0.id == stillPlaceholder.projectId }) else { return } - let session = ChatSession( - id: currentId, - projectId: project.id, - title: title, - messages: sessionStates[currentId]?.messages ?? messages, - 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) - } - - private func generateSessionTitle(firstUserMessage: String, summary: ChatSession.Summary) async -> String? { - switch summarizationProvider { - case .selectedClient: - let provider = summary.agentProvider - let model = summary.model ?? selectedSummarizationModel(for: provider) - return await generateSessionTitle(firstUserMessage: firstUserMessage, provider: provider, model: model) - case .openAI: - guard !openAISummarizationModel.isEmpty else { return nil } - return await openAISummarization.generateSessionTitle( - firstUserMessage: firstUserMessage, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) - case .appleFoundationModel: - return await foundationModelSummarization.generateSessionTitle(firstUserMessage: firstUserMessage) - } - } - - private func generateResponseNotificationSummary(responseText: String, summary: ChatSession.Summary) async -> String? { - let trimmedResponse = responseText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedResponse.isEmpty else { return nil } - - switch summarizationProvider { - case .selectedClient: - let provider = summary.agentProvider - let model = summary.model ?? selectedSummarizationModel(for: provider) - return await generateResponseNotificationSummary(responseText: trimmedResponse, provider: provider, model: model) - case .openAI: - guard !openAISummarizationModel.isEmpty else { return nil } - return await openAISummarization.generateResponseNotificationSummary( - responseText: trimmedResponse, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) - case .appleFoundationModel: - return await foundationModelSummarization.generateResponseNotificationSummary(responseText: trimmedResponse) - } - } - - private func scheduleThreadSummaryUpdate( - sessionId: String, - projectId: UUID, - cwd: String, - messages: [ChatMessage] - ) { - let userMessage = lastUserMessageText(in: messages) - let finalResponse = lastAssistantResponseText(in: messages) - guard !userMessage.isEmpty, !finalResponse.isEmpty else { return } - - let summary = allSessionSummaries.first(where: { $0.id == sessionId }) - ?? summaryFor(sessionId: sessionId, projectId: projectId) - - Task { [weak self] in - guard let self else { return } - await self.updateStoredThreadSummary( - sessionId: sessionId, - projectId: projectId, - cwd: cwd, - userMessage: userMessage, - finalResponse: finalResponse, - summary: summary - ) - } - } - - private func updateStoredThreadSummary( - sessionId: String, - projectId: UUID, - cwd: String, - userMessage: String, - finalResponse: String, - summary: ChatSession.Summary - ) async { - let previousSummary = threadStore.threadSummaryItem(sessionId: sessionId)?.summary - guard let threadSummary = await generateThreadSummary( - previousSummary: previousSummary, - userMessage: userMessage, - finalResponse: finalResponse, - summary: summary - ) else { return } - - let branchPath = summary.worktreePath ?? cwd - let currentBranch = await GitHelper.currentBranch(at: branchPath) - let branch = summary.worktreeBranch ?? currentBranch ?? "unknown" - let title = summary.title.isEmpty ? ChatSession.defaultTitle : summary.title - - threadStore.upsertThreadSummary( - sessionId: sessionId, - projectId: projectId, - branch: branch, - title: title, - summary: threadSummary - ) - threadSummaryRevision &+= 1 - - let allThreadSummaries = threadStore - .threadSummaryItems(projectId: projectId, branch: branch) - .map { (title: $0.title, summary: $0.summary) } - guard let briefing = await generateBranchBriefing( - threadSummaries: allThreadSummaries, - summary: summary - ) else { return } - - threadStore.upsertBranchBriefing(projectId: projectId, branch: branch, briefing: briefing) - branchBriefingRevision &+= 1 - } - - private func generateThreadSummary( - previousSummary: String?, - userMessage: String, - finalResponse: String, - summary: ChatSession.Summary - ) async -> String? { - switch summarizationProvider { - case .selectedClient: - let provider = summary.agentProvider - let model = summary.model ?? selectedSummarizationModel(for: provider) - return await generateThreadSummary( - previousSummary: previousSummary, - userMessage: userMessage, - finalResponse: finalResponse, - provider: provider, - model: model - ) - case .openAI: - guard !openAISummarizationModel.isEmpty else { return nil } - return await openAISummarization.generateThreadSummary( - previousSummary: previousSummary, - userMessage: userMessage, - finalResponse: finalResponse, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) - case .appleFoundationModel: - return await foundationModelSummarization.generateThreadSummary( - previousSummary: previousSummary, - userMessage: userMessage, - finalResponse: finalResponse - ) - } - } - - private func generateBranchBriefing( - threadSummaries: [(title: String, summary: String)], - summary: ChatSession.Summary - ) async -> String? { - guard !threadSummaries.isEmpty else { return nil } - switch summarizationProvider { - case .selectedClient: - let provider = summary.agentProvider - let model = summary.model ?? selectedSummarizationModel(for: provider) - return await generateBranchBriefing( - threadSummaries: threadSummaries, - provider: provider, - model: model - ) - case .openAI: - guard !openAISummarizationModel.isEmpty else { return nil } - return await openAISummarization.generateBranchBriefing( - threadSummaries: threadSummaries, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) - case .appleFoundationModel: - return await foundationModelSummarization.generateBranchBriefing(threadSummaries: threadSummaries) - } - } - - private func generateMemoryOperations( - existingMemories: [(id: String, content: String)], - userMessage: String, - finalResponse: String, - summary: ChatSession.Summary - ) async -> String? { - switch summarizationProvider { - case .selectedClient: - let provider = summary.agentProvider - let model = summary.model ?? selectedSummarizationModel(for: provider) - return await generateMemoryOperations( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse, - provider: provider, - model: model - ) - case .openAI: - guard !openAISummarizationModel.isEmpty else { return nil } - return await openAISummarization.generateMemoryOperations( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) - case .appleFoundationModel: - return await foundationModelSummarization.generateMemoryOperations( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse - ) - } - } - - /// Generates a commit message for the staged changes in the given project. - /// Routes through the configured `summarizationProvider`. Returns nil on - /// failure or when no provider is configured. Public so the Changes view - /// can invoke it from the UI thread. - /// - /// `diff` and `stat` should come from `GitHelper.stagedDiff` / - /// `GitHelper.stagedStat`. We compact them per-provider so very large - /// patches don't blow past the model's context window — the small - /// on-device Foundation Model gets a much tighter budget than the - /// cloud-hosted providers. - func generateCommitMessage( - diff: String, - stat: String, - fileSummary: String - ) async -> String? { - let raw: String? - switch summarizationProvider { - case .appleFoundationModel: - let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) - raw = await foundationModelSummarization.generateCommitMessage( - diff: context, - fileSummary: fileSummary - ) - case .openAI: - if openAISummarizationModel.isEmpty { - if FoundationModelSummarizationService.isAvailable { - let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) - raw = await foundationModelSummarization.generateCommitMessage( - diff: context, - fileSummary: fileSummary - ) - } else { - raw = nil - } - } else { - let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 16_000) - raw = await openAISummarization.generateCommitMessage( - diff: context, - fileSummary: fileSummary, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) - } - case .selectedClient: - if FoundationModelSummarizationService.isAvailable { - let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) - raw = await foundationModelSummarization.generateCommitMessage( - diff: context, - fileSummary: fileSummary - ) - } else { - let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 16_000) - raw = await claude.generateCommitMessage(diff: context, fileSummary: fileSummary) - } - } - return Self.sanitizeCommitMessage(raw) - } - - /// Builds a compact diff context that fits within `budget` characters. - /// When the raw diff fits, returns it as-is (prefixed with the stat). - /// When it doesn't, splits by `diff --git` boundaries and gives each file - /// a fair share of the remaining budget — keeping the header plus the - /// leading lines of the patch so the model still sees what changed in - /// each file, even if deeper context is dropped. - static func buildCommitContext(diff: String, stat: String, budget: Int) -> String { - let statBlock: String = { - let trimmed = stat.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "" : "Diff stat:\n\(trimmed)\n\n" - }() - - let trimmedDiff = diff.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedDiff.isEmpty { - return statBlock + "Full diff:\n(none)" - } - - // Fast path — total fits within budget. - if statBlock.count + trimmedDiff.count <= budget { - return statBlock + "Full diff:\n" + trimmedDiff - } - - // Split by file boundaries. The first chunk before any "diff --git" is - // ignored (git always starts file blocks with that marker). - let marker = "\ndiff --git " - var fileBlocks: [String] = [] - var remaining = "\n" + trimmedDiff - while let range = remaining.range(of: marker) { - let nextStart = remaining.index(range.lowerBound, offsetBy: 1) // drop leading "\n" - if let next = remaining.range(of: marker, range: range.upperBound..= budgetForDiffs { break } - } - - return statBlock + "Truncated diff (file-by-file):\n" + assembled - } - - /// Strips markdown wrappers and ensures the message starts with a - /// Conventional Commits `type:` line. Defends against models that add - /// headings, code fences, or quoted text despite explicit prompts. - private static func sanitizeCommitMessage(_ raw: String?) -> String? { - guard let raw else { return nil } - var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if text.isEmpty { return nil } - - // Strip fenced code blocks: ```...``` (any language tag). - if text.hasPrefix("```") { - var lines = text.components(separatedBy: "\n") - if !lines.isEmpty { lines.removeFirst() } - if let last = lines.last?.trimmingCharacters(in: .whitespaces), last == "```" { - lines.removeLast() - } - text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } - - // Strip surrounding quotes/backticks if the whole message is wrapped. - if let first = text.first, let last = text.last, - "\"'`".contains(first), first == last, text.count > 1 { - text = String(text.dropFirst().dropLast()) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - // Drop leading lines that look like markdown headings or empty lines - // until we reach a Conventional Commits subject (or any plain text). - let conventionalPrefixes = [ - "feat", "fix", "docs", "style", "refactor", "perf", - "test", "build", "ci", "chore", "revert" - ] - var lines = text.components(separatedBy: "\n") - while let first = lines.first { - let trimmed = first.trimmingCharacters(in: .whitespaces) - let isHeading = trimmed.hasPrefix("#") - let isEmpty = trimmed.isEmpty - let startsWithType = conventionalPrefixes.contains { type in - trimmed.lowercased().hasPrefix(type + ":") || - trimmed.lowercased().hasPrefix(type + "(") - } - if startsWithType { break } - if isHeading || isEmpty { - lines.removeFirst() - continue - } - break - } - text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? nil : text - } - - private func generateSessionTitle(firstUserMessage: String, provider: AgentProvider, model: String?) async -> String? { - switch provider { - case .claudeCode: - 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 - } - } - - private func generateThreadSummary( - previousSummary: String?, - userMessage: String, - finalResponse: String, - provider: AgentProvider, - model: String? - ) async -> String? { - switch provider { - case .claudeCode: - return await claude.generateThreadSummary( - previousSummary: previousSummary, - userMessage: userMessage, - finalResponse: finalResponse, - model: model ?? "haiku" - ) - case .codex: - return await codex.generateThreadSummary( - previousSummary: previousSummary, - userMessage: userMessage, - finalResponse: finalResponse, - model: model - ) - case .acp: - return nil - } - } - - private func generateBranchBriefing( - threadSummaries: [(title: String, summary: String)], - provider: AgentProvider, - model: String? - ) async -> String? { - switch provider { - case .claudeCode: - return await claude.generateBranchBriefing( - threadSummaries: threadSummaries, - model: model ?? "haiku" - ) - case .codex: - return await codex.generateBranchBriefing( - threadSummaries: threadSummaries, - model: model - ) - case .acp: - return nil - } - } - - private func generateMemoryOperations( - existingMemories: [(id: String, content: String)], - userMessage: String, - finalResponse: String, - provider: AgentProvider, - model: String? - ) async -> String? { - switch provider { - case .claudeCode: - return await claude.generateMemoryOperations( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse, - model: model ?? "haiku" - ) - case .codex: - return await codex.generateMemoryOperations( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse, - model: model - ) - case .acp: - return nil - } - } - - private func generateResponseNotificationSummary(responseText: String, provider: AgentProvider, model: String?) async -> String? { - switch provider { - case .claudeCode: - return await claude.generateResponseNotificationSummary(responseText: responseText, model: model ?? "haiku") - case .codex: - return await codex.generateResponseNotificationSummary(responseText: responseText, model: model) - case .acp: - // No standardized one-shot generation in ACP; keep the local preview fallback. - return nil - } - } - - private func lastAssistantResponseText(in messages: [ChatMessage]) -> String { - guard let message = messages.last(where: { $0.role == .assistant && !$0.isError }) else { - return "" - } - return message.content.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func lastUserMessageText(in messages: [ChatMessage]) -> String { - guard let message = messages.last(where: { $0.role == .user && !$0.isError }) else { - return "" - } - return ChatSession.stripAttachmentMarkers(from: message.content) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func responseNotificationFallback(from responseText: String) -> String { - let text = responseText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return "" } - let sentence = text.components(separatedBy: CharacterSet(charactersIn: ".!?\n")).first ?? text - return sentence.trimmingCharacters(in: .whitespaces) - } - - private func selectedSummarizationModel(for provider: AgentProvider) -> String? { - if selectedAgentProvider == provider { - return selectedModel - } - return availableAgentModelSections() - .first(where: { $0.provider == provider })? - .models - .first? - .id - } - - func togglePinSession(_ session: ChatSession) async { - guard let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) else { return } - allSessionSummaries[si].isPinned.toggle() - let newIsPinned = allSessionSummaries[si].isPinned - threadStore.upsert(allSessionSummaries[si]) - await updateSessionMetadata(session) { $0.isPinned = newIsPinned } - scheduleMobileSnapshotBroadcast() - } - - // MARK: - Archive - - func archiveSession(_ session: ChatSession, in window: WindowState) async { - await setArchived(session, archived: true, in: window) - } - - func unarchiveSession(_ session: ChatSession, in window: WindowState? = nil) async { - if let window { - await setArchived(session, archived: false, in: window) - } else { - await setArchivedNoWindow(session, archived: false) - } - } - - private func setArchived(_ session: ChatSession, archived: Bool, in window: WindowState) async { - // If the chat being archived is currently open in this window, swap to a - // fresh session so the user isn't stranded staring at a now-hidden chat. - if archived, window.currentSessionId == session.id { - detachCurrentStream(in: window) - startNewChat(in: window) - } - await setArchivedNoWindow(session, archived: archived) - } - - private func setArchivedNoWindow(_ session: ChatSession, archived: Bool) async { - let now = Date() - if let si = allSessionSummaries.firstIndex(where: { $0.id == session.id }) { - allSessionSummaries[si].isArchived = archived - allSessionSummaries[si].archivedAt = archived ? now : nil - threadStore.upsert(allSessionSummaries[si]) - } else { - _ = threadStore.setArchived(id: session.id, archived: archived, at: now) - } - await updateSessionMetadata(session) { s in - s.isArchived = archived - s.archivedAt = archived ? now : nil - } - scheduleMobileSnapshotBroadcast() - } - - /// Retention window (days) after which a branch briefing is purged if its - /// branch hasn't been observed locally or remotely. A branch deleted both - /// places will simply stop being touched and age out. - private static let branchBriefingRetentionDays = 30 - - /// Mark a branch as still alive on disk so its briefing isn't garbage- - /// collected. Called from views that have just observed the branch via - /// `git symbolic-ref` or similar. - func touchBranchBriefing(projectId: UUID, branch: String) { - threadStore.touchBranchBriefing(projectId: projectId, branch: branch) - } - - /// Delete branch briefings for branches that haven't been seen for the - /// retention window. Run once at app launch. - func purgeStaleBranchBriefingsIfNeeded() { - let cutoff = Calendar.current.date( - byAdding: .day, - value: -Self.branchBriefingRetentionDays, - to: Date() - ) ?? Date() - let purged = threadStore.purgeStaleBranchBriefings(olderThan: cutoff) - guard !purged.isEmpty else { return } - branchBriefingRevision &+= 1 - logger.info("Purged \(purged.count) stale branch briefings older than \(Self.branchBriefingRetentionDays) days") - } - - /// Apply the auto-archive policy: archive non-pinned chats whose `updatedAt` - /// is older than `archiveRetentionDays`. Run once at app launch. - func autoArchiveExpiredSessionsIfNeeded() { - guard autoArchiveEnabled else { return } - let cutoff = Calendar.current.date( - byAdding: .day, - value: -archiveRetentionDays, - to: Date() - ) ?? Date() - let archivedIds = threadStore.archiveStale(olderThan: cutoff) - guard !archivedIds.isEmpty else { return } - let idSet = Set(archivedIds) - let now = Date() - for idx in allSessionSummaries.indices where idSet.contains(allSessionSummaries[idx].id) { - allSessionSummaries[idx].isArchived = true - allSessionSummaries[idx].archivedAt = now - } - logger.info("Auto-archived \(archivedIds.count) chats older than \(self.archiveRetentionDays) days") - } - - /// Persist a metadata-only edit (title, pin, etc.) routing by session - /// origin. cliBacked sessions go to the sidecar; legacy sessions need the - /// full message log to be re-saved alongside the change. - /// `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. - /// Subsequent CLI invocations for this session will run in the worktree. - func attachWorktree(branch: String, in window: WindowState) async throws { - guard let project = window.selectedProject else { - throw AppError.noProjectSelected - } - let baseRepo = URL(fileURLWithPath: project.path) - let info = try await GitWorktreeService.shared.createWorktree( - baseRepo: baseRepo, - branch: branch - ) - - // New-chat view: no session yet. Park the worktree on the window so - // it gets applied when sendPrompt allocates a session id. - guard let sessionId = window.currentSessionId else { - window.pendingWorktreePath = info.path.path - window.pendingWorktreeBranch = info.branch - return - } - - // Update in-memory state - sessionStates[sessionId, default: SessionStreamState()].worktreePath = info.path.path - sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = info.branch - if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { - allSessionSummaries[idx].worktreePath = info.path.path - allSessionSummaries[idx].worktreeBranch = info.branch - threadStore.upsert(allSessionSummaries[idx]) - } - // Persist via sidecar meta - let snap = allSessionSummaries.first(where: { $0.id == sessionId }) - let fallbackProvider = defaultModelSelection(for: project).provider - let updated = (snap ?? ChatSession.Summary( - id: sessionId, projectId: project.id, title: ChatSession.defaultTitle, - createdAt: Date(), updatedAt: Date(), isPinned: false, - agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider, - worktreePath: info.path.path, worktreeBranch: info.branch - )).makeSession() - await updateSessionMetadata(updated) { s in - s.worktreePath = info.path.path - s.worktreeBranch = info.branch - } - } - - /// Switch the chat to an existing branch. - /// - /// If the branch is already attached to a linked worktree, point the - /// session at that worktree. Otherwise run `git checkout` in the project - /// root and clear the session's worktree pointer. - func switchToExistingBranch(_ branch: String, in window: WindowState) async throws { - guard let project = window.selectedProject else { - throw AppError.noProjectSelected - } - let baseRepo = URL(fileURLWithPath: project.path) - - let existingWorktree: GitWorktreeService.WorktreeInfo? = await { - guard let list = try? await GitWorktreeService.shared.listWorktrees(baseRepo: baseRepo) else { - return nil - } - // The main repo also appears in `worktree list`; skip it so the - // project root takes the plain-checkout path. - return list.first { $0.branch == branch && $0.path.standardizedFileURL != baseRepo.standardizedFileURL } - }() - - let newPath: String? - let newBranch: String? - if let existingWorktree { - newPath = existingWorktree.path.path - newBranch = existingWorktree.branch - } else { - if let err = await GitHelper.checkout(branch: branch, at: project.path) { - throw GitWorktreeService.WorktreeError.gitFailed(err) - } - newPath = nil - newBranch = nil - } - - guard let sessionId = window.currentSessionId else { - window.pendingWorktreePath = newPath - window.pendingWorktreeBranch = newBranch - return - } - - sessionStates[sessionId, default: SessionStreamState()].worktreePath = newPath - sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = newBranch - if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { - allSessionSummaries[idx].worktreePath = newPath - allSessionSummaries[idx].worktreeBranch = newBranch - threadStore.upsert(allSessionSummaries[idx]) - } - if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { - await updateSessionMetadata(snap.makeSession()) { s in - s.worktreePath = newPath - s.worktreeBranch = newBranch - } - } - } - - /// Remove the worktree associated with the session (if any). - /// `force = true` removes even with uncommitted changes. - func detachWorktree(in window: WindowState, force: Bool = false) async throws { - guard let sessionId = window.currentSessionId else { return } - let path = sessionStates[sessionId]?.worktreePath - ?? allSessionSummaries.first(where: { $0.id == sessionId })?.worktreePath - guard let path else { return } - try await GitWorktreeService.shared.removeWorktree(URL(fileURLWithPath: path), force: force) - sessionStates[sessionId]?.worktreePath = nil - sessionStates[sessionId]?.worktreeBranch = nil - if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { - allSessionSummaries[idx].worktreePath = nil - allSessionSummaries[idx].worktreeBranch = nil - threadStore.upsert(allSessionSummaries[idx]) - } - if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { - await updateSessionMetadata(snap.makeSession()) { s in - s.worktreePath = nil - s.worktreeBranch = nil - } - } - } - - /// Returns the current effective working directory for the active chat — - /// either the chat's worktree (if attached) or the project path. - func effectiveCwd(in window: WindowState) -> String? { - guard let project = window.selectedProject else { return nil } - if let sid = window.currentSessionId { - if let p = sessionStates[sid]?.worktreePath { return p } - if let p = allSessionSummaries.first(where: { $0.id == sid })?.worktreePath { return p } - } - return project.path - } - - private func updateSessionMetadata( - _ session: ChatSession, - persistTitle: Bool = false, - mutate: (inout ChatSession) -> Void - ) async { - let summary = allSessionSummaries.first(where: { $0.id == session.id }) ?? session.summary - var updated: ChatSession = switch summary.origin { - case .cliBacked: - summary.makeSession() - case .legacyRxCode, .codexAppServer, .acpAgent: - persistence.loadLegacySessionSync(projectId: session.projectId, sessionId: session.id) ?? session - } - mutate(&updated) - do { try await persistence.saveSession(updated, persistTitle: persistTitle) } - catch { logger.error("Failed to save session metadata: \(error.localizedDescription)") } - } - - func renameProject(_ project: Project, to newName: String) async { - let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - let index = projects.firstIndex(where: { $0.id == project.id }) else { return } - projects[index].name = trimmed - do { - try await persistence.saveProjects(projects) - } catch { - logger.error("Failed to save projects after rename: \(error.localizedDescription)") - } - } - - func deleteProject(_ project: Project, in window: WindowState) async { - // Switch away if the deleted project is currently selected - if window.selectedProject?.id == project.id { - let next = projects.first(where: { $0.id != project.id }) - if let next { - selectProject(next, in: window) - } else { - window.selectedProject = nil - window.currentSessionId = nil - } - } - - // Cascade: delete each session's stored messages (CLI jsonl, meta, - // legacy json) before discarding the project itself. Without this the - // jsonls remain on disk under the project's cwd and would resurface as - // orphan sessions if the same path is added back as a project later. - let projectSummaries = allSessionSummaries.filter { $0.projectId == project.id } - for summary in projectSummaries { - let cwd = summary.worktreePath ?? project.path - do { - try await persistence.deleteSession( - projectId: summary.projectId, - sessionId: summary.id, - origin: summary.origin, - cwd: cwd - ) - } catch { - logger.error("Failed to delete session \(summary.id) on project delete: \(error.localizedDescription)") - } - sessionStates.removeValue(forKey: summary.id) - } - - // Remove all in-memory session summaries for this project - threadStore.deleteAll(projectId: project.id) - let projectId = project.id - Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } - Task.detached(priority: .utility) { [memoryService] in await memoryService.deleteAll(projectId: projectId) } - allSessionSummaries.removeAll { $0.projectId == project.id } - - // Remove from projects list and persist - projects.removeAll { $0.id == project.id } - do { - try await persistence.saveProjects(projects) - } catch { - logger.error("Failed to save projects after deletion: \(error.localizedDescription)") - } - } - - func deleteSession(_ session: ChatSession, in window: WindowState) async { - if window.currentSessionId == session.id { - detachCurrentStream(in: window) - startNewChat(in: window) - } - let summary = allSessionSummaries.first(where: { $0.id == session.id }) - let origin = summary?.origin ?? session.origin - // Prefer the worktreePath used while writing the jsonl — otherwise the - // CLI jsonl (stored under that cwd) is orphaned on disk and resurrects - // on next reload. Fall back to the project's path, then the session's. - let cwd = summary?.worktreePath - ?? session.worktreePath - ?? projects.first(where: { $0.id == session.projectId })?.path - do { - try await persistence.deleteSession(projectId: session.projectId, sessionId: session.id, origin: origin, cwd: cwd) - } catch { - logger.error("Failed to delete session: \(error.localizedDescription)") - } - allSessionSummaries.removeAll { $0.id == session.id } - threadStore.delete(id: session.id) - let deletedId = session.id - Task.detached(priority: .utility) { [searchService] in await searchService.removeThread(id: deletedId) } - sessionStates.removeValue(forKey: session.id) - } - - func deleteAllSessions(projectId: UUID? = nil, archivedOnly: Bool = false, in window: WindowState) async { - var toDelete: [ChatSession.Summary] - if let projectId { - toDelete = allSessionSummaries.filter { $0.projectId == projectId } - } else { - toDelete = allSessionSummaries - } - if archivedOnly { - toDelete = toDelete.filter { $0.isArchived } - } - let ids = Set(toDelete.map(\.id)) - - // Only disrupt the current window's stream if its session is actually - // being deleted — otherwise a project-scoped delete would clobber an - // unrelated streaming session. - if let currentId = window.currentSessionId, ids.contains(currentId) { - detachCurrentStream(in: window) - startNewChat(in: window) - } - - for summary in toDelete { - let cwd = summary.worktreePath - ?? projects.first(where: { $0.id == summary.projectId })?.path - do { - try await persistence.deleteSession( - projectId: summary.projectId, - sessionId: summary.id, - origin: summary.origin, - cwd: cwd - ) - } catch { - logger.error("Failed to delete session \(summary.id): \(error.localizedDescription)") - } - } - - allSessionSummaries.removeAll { ids.contains($0.id) } - if archivedOnly { - for id in ids { - threadStore.delete(id: id) - } - let snapshotIds = ids - Task.detached(priority: .utility) { [searchService] in - for id in snapshotIds { await searchService.removeThread(id: id) } - } - } else { - threadStore.deleteAll(projectId: projectId) - if let projectId { - Task.detached(priority: .utility) { [searchService] in await searchService.removeProject(id: projectId) } - } else { - let snapshotIds = ids - Task.detached(priority: .utility) { [searchService] in - for id in snapshotIds { await searchService.removeThread(id: id) } - } - } - } - for id in ids { - sessionStates.removeValue(forKey: id) - } - } - - func selectSession(id: String, in window: WindowState) { - logger.info("[SelectSession] click sid=\(id, privacy: .public) currentSid=\(window.currentSessionId ?? "", privacy: .public) selectedProject=\(window.selectedProject?.id.uuidString ?? "", privacy: .public) summariesCount=\(self.allSessionSummaries.count)") - guard window.currentSessionId != id else { - if window.showingBriefing { - window.showingBriefing = false - window.requestInputFocus = true - logger.info("[SelectSession] same sid, leaving briefing sid=\(id, privacy: .public)") - } else { - logger.info("[SelectSession] no-op: already current sid=\(id, privacy: .public)") - } - return - } - - window.cancelSessionSwitchTask() - - if let summary = allSessionSummaries.first(where: { $0.id == 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) - window.requestInputFocus = true - window.setSessionSwitchTask(Task { - guard !Task.isCancelled else { return } - await didSwitchToSession(session) - }) - return - } - - // 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 { - logger.error("[SelectSession] summary or project missing for sid=\(id, privacy: .public)") - return - } - - logger.info("[SelectSession] cross-project switch sid=\(id, privacy: .public) toProject=\(project.id.uuidString, privacy: .public)") - window.setSessionSwitchTask(Task { [weak self] in - guard let self else { return } - guard !Task.isCancelled else { return } - selectProject(project, in: window) - guard !Task.isCancelled else { return } - 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) - { - logger.info("[SelectSession] cross-project preload ok sid=\(id, privacy: .public) messages=\(full.messages.count)") - switchToSession(full, messages: full.messages, in: window) - } else { - logger.info("[SelectSession] cross-project preload empty sid=\(id, privacy: .public)") - switchToSession(session, in: window) - } - window.requestInputFocus = true - await didSwitchToSession(session) - } - }) - } - - func addProject(_ project: Project) { - guard !projects.contains(where: { $0.path == project.path }) else { return } - projects.append(project) - Task { - do { try await persistence.saveProjects(projects) } - catch { logger.error("Failed to save projects: \(error.localizedDescription)") } - } - } - - // MARK: - Marketplace - - func loadMarketplace(forceRefresh: Bool = false) async { - marketplaceLoading = true - marketplaceSourceError = nil - defer { marketplaceLoading = false } - - async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh) - async let installed = marketplace.installedPluginNames() - - let fetchedCatalog = await catalog - let installedNames = await installed - await marketplace.importInstalledPlugins(catalog: fetchedCatalog, installedNames: installedNames) - - marketplaceCatalog = fetchedCatalog - marketplaceInstalledNames = installedNames - marketplaceCustomSources = await marketplace.customSources() - } - - func installMarketplacePlugin(_ plugin: MarketplacePlugin) async { - marketplacePluginStates[plugin.id] = .installing - do { - try await marketplace.installPlugin(plugin) - marketplacePluginStates[plugin.id] = .installed - marketplaceInstalledNames.insert(plugin.name) - } catch { - marketplacePluginStates[plugin.id] = .failed(error.localizedDescription) - logger.error("Failed to install plugin \(plugin.name): \(error.localizedDescription)") - } - } - - func uninstallMarketplacePlugin(_ plugin: MarketplacePlugin) async { - do { - try await marketplace.uninstallPlugin(plugin) - marketplaceInstalledNames.remove(plugin.name) - marketplacePluginStates[plugin.id] = .notInstalled - } catch { - logger.error("Failed to uninstall plugin \(plugin.name): \(error.localizedDescription)") - } - } - - @discardableResult - func addMarketplaceGitSource(url: String, ref: String?) async -> Bool { - marketplaceSourceError = nil - do { - _ = try await marketplace.addCustomGitSource(url: url, ref: ref) - marketplaceCustomSources = await marketplace.customSources() - await loadMarketplace(forceRefresh: true) - return true - } catch { - marketplaceSourceError = error.localizedDescription - logger.error("Failed to add marketplace Git source: \(error.localizedDescription)") - return false - } - } - - @discardableResult - func removeMarketplaceGitSource(_ source: MarketplaceCustomSource) async -> Bool { - marketplaceSourceError = nil - do { - try await marketplace.removeCustomGitSource(source) - marketplaceCustomSources = await marketplace.customSources() - await loadMarketplace(forceRefresh: true) - return true - } catch { - marketplaceSourceError = error.localizedDescription - logger.error("Failed to remove marketplace Git source: \(error.localizedDescription)") - return false - } - } - - // MARK: - Attachment Management - - func addAttachment(_ attachment: Attachment, in window: WindowState) { - window.attachments.append(attachment) - } - - func removeAttachment(_ id: UUID, in window: WindowState) { - window.removeAttachment(id) - } - - private func buildPromptWithAttachments(_ text: String, attachments: [Attachment]) -> String { - guard !attachments.isEmpty else { return text } - let attachmentLines = attachments.map(\.promptContext).joined(separator: "\n") - let userText = text.isEmpty ? "See attached files" : text - return "\(attachmentLines)\n\n\(userText)" - } - - // MARK: - Private Helpers - - /// Extract the last response time from the message list. Based on the last assistant message; falls back to the last message, then current time. - private func lastResponseDate(from messages: [ChatMessage]) -> Date { - messages.last(where: { $0.role == .assistant })?.timestamp - ?? messages.last?.timestamp - ?? Date() - } - - private func cleanLoadedMessages(_ raw: [ChatMessage]) -> [ChatMessage] { - raw.compactMap { message in - var msg = message - msg.isStreaming = false - if msg.blocks.isEmpty, msg.role == .assistant { return nil } - return msg - } - } + var isFetchingRepos = false /// Last seen jsonl byte size per session — used as a cheap drift signal /// in `reconcileFromDisk` so the no-drift path skips the full mmap+parse. - private var lastReconciledJsonlSize: [String: UInt64] = [:] - - /// Build the routing summary for `persistence.loadFullSession`. Falls back - /// to a synthesized `.cliBacked` summary when the session hasn't been - /// indexed yet (e.g. brand-new session whose `.result` arrived before the - /// summary list refresh). - private func summaryFor(sessionId: String, projectId: UUID) -> ChatSession.Summary { - let fallbackProvider = defaultModelSelection(for: projects.first { $0.id == projectId }).provider - return allSessionSummaries.first(where: { $0.id == sessionId }) - ?? ChatSession.Summary( - id: sessionId, projectId: projectId, title: "", - createdAt: Date(), updatedAt: Date(), isPinned: false, - agentProvider: sessionStates[sessionId]?.agentProvider ?? fallbackProvider, - origin: (sessionStates[sessionId]?.agentProvider ?? fallbackProvider).defaultSessionOrigin - ) - } - - /// Reload messages from the CLI's jsonl on disk and fill any blocks the - /// live stream may have missed (e.g. ownership-transfer races, observation - /// re-subscribe gaps). Fired off as a detached task so the stream loop is - /// not delayed by the mmap parse. - /// - /// Replacement is gated on disk having strictly more block content than - /// memory, so the common no-drift case produces no UI churn. Before the - /// parse, the file size is compared against the last seen size — if the - /// jsonl hasn't grown, the parse is skipped entirely. - private func reconcileFromDisk(sessionId: String, projectId: UUID, cwd: String) { - let summary = summaryFor(sessionId: sessionId, projectId: projectId) - let lastSize = lastReconciledJsonlSize[sessionId] - - Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return } - - let url = await self.cliStore.directory(forCwd: cwd) - .appendingPathComponent("\(sessionId).jsonl") - let currentSize: UInt64? = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int) - .flatMap(UInt64.init(exactly:)) - if let lastSize, let currentSize, currentSize <= lastSize { return } - - guard let full = await self.persistence.loadFullSession(summary: summary, cwd: cwd) else { return } - let cleaned = await self.cleanLoadedMessages(full.messages) - await MainActor.run { - guard var state = self.sessionStates[sessionId], !state.isStreaming else { return } - if let currentSize { self.lastReconciledJsonlSize[sessionId] = currentSize } - let memBlocks = state.messages.reduce(0) { $0 + $1.blocks.count } - let diskBlocks = cleaned.reduce(0) { $0 + $1.blocks.count } - guard diskBlocks > memBlocks else { return } - self.logger.info("[Reconcile] sid=\(sessionId, privacy: .public) memBlocks=\(memBlocks) diskBlocks=\(diskBlocks) — applied") - state.messages = cleaned - self.sessionStates[sessionId] = state - } - } - } - - /// Load messages in the background and inject without blocking the main thread. - /// Does not overwrite if currently streaming or if messages already exist. - /// `cwd` is needed so we can route to the CLI's jsonl when origin is `.cliBacked`. - private func loadMessagesInBackground(projectId: UUID, sessionId: String, cwd: String) { - // Snapshot the summary while we're on MainActor so the detached task - // can route by origin without awaiting back to us first. - let summary = summaryFor(sessionId: sessionId, projectId: projectId) - logger.info("[LoadMessages] start sid=\(sessionId, privacy: .public) origin=\(String(describing: summary.origin), privacy: .public) cwd=\(cwd, privacy: .public)") - - Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { return } - let full = await self.persistence.loadFullSession(summary: summary, cwd: cwd) - let cleaned: [ChatMessage] - if let full { - cleaned = await self.cleanLoadedMessages(full.messages) - } else { - cleaned = [] - } - await MainActor.run { - let rawCount = full?.messages.count ?? -1 - self.logger.info("[LoadMessages] loaded sid=\(sessionId, privacy: .public) rawMessages=\(rawCount) cleaned=\(cleaned.count)") - guard var state = self.sessionStates[sessionId] else { - self.logger.error("[LoadMessages] dropped — no sessionState sid=\(sessionId, privacy: .public)") - return - } - // Always clear the loading flag, even if we bail out — otherwise the UI - // would stay faded out forever on the failure / skip paths. - state.isLoadingFromDisk = false - defer { self.sessionStates[sessionId] = state } - guard let full else { - self.logger.error("[LoadMessages] no session returned by persistence sid=\(sessionId, privacy: .public) origin=\(String(describing: summary.origin), privacy: .public)") - return - } - guard !state.isStreaming, state.messages.isEmpty else { - self.logger.info("[LoadMessages] skipped apply sid=\(sessionId, privacy: .public) isStreaming=\(state.isStreaming) existingMessages=\(state.messages.count)") - return - } - state.messages = cleaned - state.planDecisionSummaries = self.threadStore.loadPlanDecisions(sessionId: sessionId) - if state.model == nil { state.model = full.model } - if state.effort == nil { state.effort = full.effort } - if state.permissionMode == nil { state.permissionMode = full.permissionMode } - self.logger.info("[LoadMessages] applied sid=\(sessionId, privacy: .public) messages=\(state.messages.count) planDecisions=\(state.planDecisionSummaries.count)") - } - } - } - - private func saveCurrentSession(in window: WindowState) async { - guard let project = window.selectedProject, - let sessionId = window.currentSessionId else { return } - await saveSession( - sessionId: sessionId, - projectId: project.id, - messages: stateForSession(sessionId).messages - ) - } - - private func saveSession(sessionId: String, projectId: UUID, messages: [ChatMessage]) async { - guard !messages.isEmpty else { return } - - // 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 = summary, !existing.title.isEmpty { - title = existing.title - } else { - title = ChatSession.defaultTitle - } - - let sessionModel = sessionStates[sessionId]?.model - ?? summary?.model - let sessionAgentProvider = sessionStates[sessionId]?.agentProvider - ?? summary?.agentProvider - ?? selectedAgentProvider - let sessionEffort = sessionStates[sessionId]?.effort - ?? summary?.effort - let sessionPermissionMode = sessionStates[sessionId]?.permissionMode - ?? 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, - worktreePath: summary?.worktreePath, - worktreeBranch: summary?.worktreeBranch, - isArchived: summary?.isArchived ?? false, - archivedAt: summary?.archivedAt - ) - - do { - try await persistence.saveSession(session) - } catch { - logger.error("Failed to save session \(sessionId): \(error.localizedDescription)") - } - - // Update allSessionSummaries — skipped while streaming (updated only once after completion) - let isCurrentlyStreaming = sessionStates[sessionId]?.isStreaming ?? false - if !isCurrentlyStreaming { - let summary = session.summary - withAnimation(nil) { - while allSessionSummaries.filter({ $0.id == sessionId }).count > 1, - let lastIdx = allSessionSummaries.lastIndex(where: { $0.id == sessionId }) - { - allSessionSummaries.remove(at: lastIdx) - } - if let index = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { - allSessionSummaries[index] = summary - } else { - allSessionSummaries.insert(summary, at: 0) - } - } - threadStore.upsert(summary) - - // Update the on-device semantic index. Skipped while streaming so - // we only embed a thread once it has settled. - let snapshot = session - Task.detached(priority: .utility) { [searchService] in - await searchService.indexThread(snapshot) - } - } - - // Update the project's lastSessionId - if let index = projects.firstIndex(where: { $0.id == projectId }) { - projects[index].lastSessionId = sessionId - do { - try await persistence.saveProjects(projects) - } catch { - logger.error("Failed to save projects: \(error.localizedDescription)") - } - } - } - - private func saveDraft(in window: WindowState) { - let key = draftKey(for: window) - let trimmed = window.inputText.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { window.draftTexts.removeValue(forKey: key) } - else { window.draftTexts[key] = window.inputText } - } - - private func saveQueue(in window: WindowState) { - let key = queueKey(for: window) - if window.messageQueue.isEmpty { window.draftQueues.removeValue(forKey: key) } - else { window.draftQueues[key] = window.messageQueue } - } - - /// Persistence key used for both the in-memory `draftQueues` mirror and the - /// SwiftData `QueuedMessageRecord.sessionKey` column. - private func queueKey(for window: WindowState) -> String { - draftKey(for: window) - } - - private func draftKey(for window: WindowState) -> String { - window.currentSessionId ?? newDraftKey(for: window) - } - - private func newDraftKey(for window: WindowState) -> String { - guard let projectId = window.selectedProject?.id else { return "new" } - return "new:\(projectId.uuidString)" - } - - private func renameDraftState(from oldKey: String, to newKey: String, in window: WindowState) { - guard oldKey != newKey else { return } - - if let text = window.draftTexts.removeValue(forKey: oldKey), - window.draftTexts[newKey] == nil { - window.draftTexts[newKey] = text - } - - guard let queue = window.draftQueues.removeValue(forKey: oldKey) else { return } - if var existing = window.draftQueues[newKey] { - existing.append(contentsOf: queue) - window.draftQueues[newKey] = existing - } else { - window.draftQueues[newKey] = queue - } - } - - // MARK: - Message Queue (persisted) - - func enqueueMessage(text: String, attachments: [Attachment], in window: WindowState) { - let message = QueuedMessage(text: text, attachments: attachments) - window.messageQueue.append(message) - let key = queueKey(for: window) - window.draftQueues[key] = window.messageQueue - threadStore.appendQueued(sessionKey: key, message: message) - broadcastMobileSessionStatus(sessionID: key) - } - - func removeQueuedMessage(id: UUID, in window: WindowState) { - window.dequeueMessage(id: id) - saveQueue(in: window) - threadStore.removeQueued(id: id) - broadcastMobileSessionStatus(sessionID: queueKey(for: window)) - } - - /// Pops the head of the queue (the auto-flush path used when a stream ends) - /// and removes the persisted row. - func dequeueNextForFlush(in window: WindowState) -> QueuedMessage? { - guard let next = window.dequeueNext() else { return nil } - saveQueue(in: window) - threadStore.removeQueued(id: next.id) - broadcastMobileSessionStatus(sessionID: queueKey(for: window)) - return next - } - - /// Cancels any in-flight stream for the current session, removes the chosen - /// queued message, and sends it as the next user turn. - func sendQueuedNow(id: UUID, in window: WindowState) async { - guard let target = window.messageQueue.first(where: { $0.id == id }) else { return } - // Take a snapshot of remaining queue items so we can restore them after - // `cancelStreaming` clobbers `window.inputText`/`window.attachments`. - let remaining = window.messageQueue.filter { $0.id != id } - let draftText = window.inputText - let draftAttachments = window.attachments - let shouldRestoreDraft = !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || !draftAttachments.isEmpty - - // Clear the queue from memory + disk first, so the cancellation path - // doesn't see the queued item we're about to send. - let key = queueKey(for: window) - window.messageQueue.removeAll() - window.draftQueues.removeValue(forKey: key) - threadStore.clearQueue(sessionKey: key) - - if isStreaming(in: window) { - await cancelStreaming(in: window) - } - - // Restore the remaining items into memory + disk. - for msg in remaining { - window.messageQueue.append(msg) - threadStore.appendQueued(sessionKey: key, message: msg) - } - if remaining.isEmpty { - window.draftQueues.removeValue(forKey: key) - } else { - window.draftQueues[key] = window.messageQueue - } - - window.inputText = target.text - window.attachments = target.attachments - await send(in: window) - if shouldRestoreDraft { - window.inputText = draftText - window.attachments = draftAttachments - } - broadcastMobileSessionStatus(sessionID: key) - } - - /// Concatenates every queued message (texts joined with a blank line, - /// attachments merged in order), cancels any in-flight stream, clears the - /// queue, then sends the combined message as a single user turn. - func sendAllQueuedAsOne(in window: WindowState) async { - guard !window.messageQueue.isEmpty else { return } - let snapshot = window.messageQueue - let draftText = window.inputText - let draftAttachments = window.attachments - let shouldRestoreDraft = !draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || !draftAttachments.isEmpty - - let key = queueKey(for: window) - window.messageQueue.removeAll() - window.draftQueues.removeValue(forKey: key) - threadStore.clearQueue(sessionKey: key) - - if isStreaming(in: window) { - await cancelStreaming(in: window) - } - - let combinedText = snapshot - .map(\.text) - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .joined(separator: "\n\n") - let combinedAttachments = snapshot.flatMap(\.attachments) - - window.inputText = combinedText - window.attachments = combinedAttachments - await send(in: window) - if shouldRestoreDraft { - window.inputText = draftText - window.attachments = draftAttachments - } - broadcastMobileSessionStatus(sessionID: key) - } - - /// Sends the next queued message for a background session (one the window is not currently displaying). - /// Foreground session queues are handled by InputBarView via the isStreaming onChange handler. - private func processBackgroundQueue( - for sessionKey: String, - projectId: UUID, - cwd: String, - in window: WindowState - ) async { - guard sessionStates[sessionKey]?.isStreaming != true else { return } - guard var queue = window.draftQueues[sessionKey], !queue.isEmpty else { return } - let next = queue.removeFirst() - if queue.isEmpty { window.draftQueues.removeValue(forKey: sessionKey) } - else { window.draftQueues[sessionKey] = queue } - threadStore.removeQueued(id: next.id) - - let (resolvedAttachments, tempFilePaths) = AttachmentFactory.resolvingClipboardImages(next.attachments) - let prompt = buildPromptWithAttachments(next.text, attachments: resolvedAttachments) - let displayText = next.text - let streamId = UUID() - - updateState(sessionKey) { state in - state.messages.append(ChatMessage(role: .user, content: displayText, attachments: resolvedAttachments)) - state.inFlightUserAttachments = resolvedAttachments - state.isStreaming = true - state.hasUncheckedCompletion = false - state.activeStreamId = streamId - state.streamingStartDate = Date() - state.currentTurnOutputTokensByMessage.removeAll(keepingCapacity: true) - state.currentTurnOutputTokensUnkeyed = 0 - } - - let currentPermissionMode = sessionStates[sessionKey]?.permissionMode ?? permissionMode - let projectSelection = defaultModelSelection(for: projects.first { $0.id == projectId }) - let agentProvider = sessionStates[sessionKey]?.agentProvider ?? projectSelection.provider - var hookSettingsPath: String? - if agentProvider == .claudeCode, !currentPermissionMode.skipsHookPipeline { - do { hookSettingsPath = try await permission.writeHookSettingsFile() } - catch { logger.error("Failed to write hook settings for background queue: \(error.localizedDescription)") } - } - - await permission.registerSession(sid: sessionKey, projectKey: cwd, mode: currentPermissionMode) - - let model = sessionStates[sessionKey]?.model ?? projectSelection.model - let effort = sessionStates[sessionKey]?.effort ?? (selectedEffort == "auto" ? nil : selectedEffort) - let task = Task { [weak self, window] in - guard let self else { return } - await self.processStream( - streamId: streamId, - prompt: prompt, - cwd: cwd, - cliSessionId: sessionKey, - internalSessionKey: sessionKey, - agentProvider: agentProvider, - model: model, - effort: effort, - hookSettingsPath: hookSettingsPath, - permissionMode: currentPermissionMode, - projectId: projectId, - window: window - ) - for path in tempFilePaths { - try? FileManager.default.removeItem(atPath: path) - } - } - sessionStates[sessionKey]?.streamTask = task - } - - private func handleError(_ error: Error, in window: WindowState) { - logger.error("AppState error: \(error.localizedDescription)") - addErrorMessage(error.localizedDescription, in: window) - } - - private func addErrorMessage(_ text: String, in window: WindowState) { - let key = window.currentSessionId ?? window.newSessionKey - let msg = ChatMessage(role: .assistant, content: text, isError: true) - updateState(key) { $0.messages.append(msg) } - } - - // MARK: - Claude Settings Reader - - private nonisolated static func readPermissionModeFromSettings() -> PermissionMode { - let url = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude/settings.json") - if let data = try? Data(contentsOf: url), - 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) - { - return parsed - } - if let saved = UserDefaults.standard.string(forKey: "selectedPermissionMode"), - let parsed = PermissionMode(rawValue: saved) - { - return parsed - } - return .default - } + var lastReconciledJsonlSize: [String: UInt64] = [:] } // MARK: - App Errors -private enum AppError: LocalizedError { +enum AppError: LocalizedError { case noProjectSelected case claudeNotInstalled case streamFailed(String) diff --git a/RxCode/Services/ACPService+Helpers.swift b/RxCode/Services/ACPService+Helpers.swift new file mode 100644 index 0000000..cf9695b --- /dev/null +++ b/RxCode/Services/ACPService+Helpers.swift @@ -0,0 +1,313 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - File / Capability / Tool-Call Helpers + +extension ACPService { + + // MARK: - File Helpers + + 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 + } + + func agentSupportsMCPTransport(_ transport: String, initResult: JSONValue) -> Bool { + guard transport == "http" || transport == "sse" else { return true } + return initResult + .objectValue?["agentCapabilities"]? + .objectValue?["mcpCapabilities"]? + .objectValue?[transport]? + .boolValue ?? false + } + + func supportedMCPServers(_ servers: [JSONValue], initResult: JSONValue) -> [JSONValue] { + let mcpCaps = initResult.objectValue?["agentCapabilities"]?.objectValue?["mcpCapabilities"] + logger.info("[ACP-MCP] agent advertised mcpCapabilities=\(mcpCaps?.shortDescription ?? "", privacy: .public) (http=\(mcpCaps?.objectValue?["http"]?.boolValue == true) sse=\(mcpCaps?.objectValue?["sse"]?.boolValue == true))") + + var supported: [JSONValue] = [] + var dropped: [String] = [] + + for server in servers { + let obj = server.objectValue + let transport = obj?["type"]?.stringValue ?? "stdio" + let name = obj?["name"]?.stringValue ?? "" + if agentSupportsMCPTransport(transport, initResult: initResult) { + logger.info("[ACP-MCP] passing entry name=\(name, privacy: .public) transport=\(transport, privacy: .public)") + supported.append(server) + } else { + logger.info("[ACP-MCP] dropping entry name=\(name, privacy: .public) transport=\(transport, privacy: .public) — agent doesn't advertise capability") + dropped.append("\(name):\(transport)") + } + } + + if !dropped.isEmpty { + logger.info("[ACP-MCP] dropping unsupported MCP transports: \(dropped.joined(separator: ", "), privacy: .public)") + } + logger.info("[ACP-MCP] passing \(supported.count, privacy: .public)/\(servers.count, privacy: .public) mcpServers after initialize capability check") + return supported + } + + /// Scans a `session/new` (or `session/load`) response for the first + /// `SessionConfigOption` with `category: "model"` and `type: "select"`, + /// flattening grouped options. + 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 { + 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 + } + + // MARK: - Tool Call Normalization + // + // ACP `tool_call` notifications expose two views of a tool invocation: + // a machine-readable `kind` ("edit", "execute", "read", …) and a + // human-readable `title`. The agent's `rawInput` is opaque (each agent + // chooses its own schema), but the optional `content` array surfaces + // structured `diff` entries (`{path, oldText, newText}`) that we can + // translate into the Claude-shaped `Edit`/`Write`/`MultiEdit` input keys + // the rest of RxCode (sidebar persistence, diff renderer, plan card) + // already understands. + + struct NormalizedToolCall { + let name: String + let input: [String: JSONValue] + } + + /// Extracts `{type: "diff", path, oldText, newText}` entries from a + /// session/update payload's `content` array. Returns `[]` for non-edit + /// kinds or absent content. + static func diffEntries(in update: [String: JSONValue]) -> [(path: String, oldText: String?, newText: String)] { + guard let content = update["content"]?.arrayValue else { return [] } + var out: [(path: String, oldText: String?, newText: String)] = [] + for entry in content { + guard let obj = entry.objectValue, + obj["type"]?.stringValue == "diff", + let path = obj["path"]?.stringValue, + let newText = obj["newText"]?.stringValue + else { continue } + let oldText = obj["oldText"]?.stringValue + out.append((path: path, oldText: oldText, newText: newText)) + } + return out + } + + /// Translates an ACP tool_call payload into a Claude-shaped (name, input). + /// Edit-kind calls with diff content become `Edit`/`Write`/`MultiEdit` so + /// `ChatMessage.fileEditHunks` and `editedFilePath` can extract the path + /// and hunks for sidebar persistence. Other kinds fall back to the agent's + /// title + rawInput unchanged. + static func normalizeToolCall( + kind: String?, + title: String, + update: [String: JSONValue], + rawInput: [String: JSONValue] + ) -> NormalizedToolCall { + let normalizedKind = (kind ?? "").lowercased() + let diffs = diffEntries(in: update) + if normalizedKind == "edit" || !diffs.isEmpty { + if !diffs.isEmpty { + if diffs.count == 1 { + let only = diffs[0] + let oldText = only.oldText ?? "" + if oldText.isEmpty { + return NormalizedToolCall( + name: "Write", + input: [ + "file_path": .string(only.path), + "content": .string(only.newText) + ] + ) + } + return NormalizedToolCall( + name: "Edit", + input: [ + "file_path": .string(only.path), + "old_string": .string(oldText), + "new_string": .string(only.newText) + ] + ) + } + let primaryPath = diffs.first!.path + let edits: [JSONValue] = diffs.filter { $0.path == primaryPath }.map { d in + .object([ + "old_string": .string(d.oldText ?? ""), + "new_string": .string(d.newText) + ]) + } + return NormalizedToolCall( + name: "MultiEdit", + input: [ + "file_path": .string(primaryPath), + "edits": .array(edits) + ] + ) + } + var input = rawInput + if input["file_path"] == nil, + let path = (update["locations"]?.arrayValue?.first?.objectValue?["path"]?.stringValue) { + input["file_path"] = .string(path) + } + return NormalizedToolCall(name: "Edit", input: input) + } + + return NormalizedToolCall(name: title, input: rawInput) + } + + /// Wraps a text chunk in the same raw-event shape Claude's CLI emits, so + /// `AppState.handlePartialEvent` accumulates it into `state.textDeltaBuffer`. + 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 + } + + 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 + } + + 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 + ) + } + + static func modelListDescription(_ options: [ACPModelOption]) -> String { + options.map { option in + option.name == option.value ? option.value : "\(option.value) (\(option.name))" + }.joined(separator: ", ") + } +} + +// MARK: - JSONValue Conveniences + +extension JSONValue { + nonisolated var objectValue: [String: JSONValue]? { + if case .object(let o) = self { return o } + return nil + } + nonisolated var arrayValue: [JSONValue]? { + if case .array(let a) = self { return a } + return nil + } + nonisolated var stringValue: String? { + if case .string(let s) = self { return s } + return nil + } + nonisolated var boolValue: Bool? { + if case .bool(let b) = self { return b } + return nil + } + nonisolated var intValue: Int? { + if case .number(let n) = self { return Int(n) } + return nil + } + nonisolated var shortDescription: String { + switch self { + case .object(let o): return "object(\(o.count) keys)" + case .array(let a): return "array(\(a.count))" + case .string(let s): return s.prefix(80).description + case .number(let n): return "\(n)" + case .bool(let b): return "\(b)" + case .null: return "null" + } + } +} + +// MARK: - AgentBackend Conformance + +extension ACPService: AgentBackend { + nonisolated var provider: AgentProvider { .acp } + nonisolated var staticCapabilities: CapabilitySet { AgentProvider.acp.staticCapabilities } + + func send(_ request: BackendSendRequest) -> AsyncStream { + guard let spec = request.acpSpec else { + return 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: request.sessionId ?? request.clientSessionKey, + isError: true, totalTurns: nil, usage: nil, contextWindow: nil + ))) + c.finish() + } + } + return send( + streamId: request.streamId, + prompt: request.prompt, + cwd: request.cwd, + sessionId: request.sessionId, + model: request.model, + spec: spec, + permissionMode: request.permissionMode, + clientSessionKey: request.clientSessionKey, + mcpServers: request.acpMCPServers + ) + } +} diff --git a/RxCode/Services/ACPService+Protocol.swift b/RxCode/Services/ACPService+Protocol.swift new file mode 100644 index 0000000..72e1367 --- /dev/null +++ b/RxCode/Services/ACPService+Protocol.swift @@ -0,0 +1,455 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - JSON-RPC Framing, Read Loop & Agent Messages + +extension ACPService { + + // MARK: - JSON-RPC Framing + + func sendRequest(key: String, method: String, params: [String: JSONValue]) + async throws -> JSONValue + { + guard sessions[key] != nil else { throw ACPError.streamClosed } + + let id = nextRequestId(key: key) + let frame: [String: JSONValue] = [ + "jsonrpc": .string("2.0"), + "id": .number(Double(id)), + "method": .string(method), + "params": .object(params) + ] + try writeFrame(key: key, frame: .object(frame)) + logger.info("[ACP] → \(method, privacy: .public) id=\(id) key=\(key, privacy: .public)") + + return try await withCheckedThrowingContinuation { cont in + mutateSession(key) { $0.pending[id] = cont } + } + } + + func sendResult(key: String, id: JSONValue, result: JSONValue) { + let frame: [String: JSONValue] = [ + "jsonrpc": .string("2.0"), + "id": id, + "result": result + ] + try? writeFrame(key: key, frame: .object(frame)) + } + + func sendError(key: String, 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(key: key, frame: .object(frame)) + } + + func writeFrame(key: String, frame: JSONValue) throws { + guard let entry = sessions[key] else { throw ACPError.streamClosed } + let data = try JSONEncoder().encode(frame) + var line = data + line.append(0x0A) + try entry.stdin.write(contentsOf: line) + } + + func nextRequestId(key: String) -> Int { + var id = 0 + mutateSession(key) { entry in + id = entry.nextId + entry.nextId += 1 + } + return id + } + + func mutateSession(_ key: String, _ mutate: (inout SessionEntry) -> Void) { + guard var entry = sessions[key] else { return } + mutate(&entry) + sessions[key] = 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. + + static func spawnStdoutReader( + key: String, + 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 + await service?.logReaderStarted(key: key) + var buffer = Data() + for await chunk in chunkStream { + if Task.isCancelled { + await service?.logReaderCancelled(key: key) + 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(key: key, 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"] { + await handleAgentRequest(key: key, id: idVal, method: method, params: params) + } else { + handleAgentNotification(key: key, method: method, params: params) + } + } + + func resolveResponse(key: String, id: Int, body: [String: JSONValue]) { + var cont: CheckedContinuation? + mutateSession(key) { entry in + cont = entry.pending.removeValue(forKey: id) + } + guard let cont else { + logger.warning("[ACP] ← response id=\(id) had no pending continuation key=\(key, 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 + + func handleAgentNotification(key: String, method: String, params: JSONValue) { + switch method { + case "session/update": + guard sessions[key]?.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(key: key, params: params) + default: + logger.warning("[ACP] ⟵ unknown notification: \(method, privacy: .public)") + } + } + + func handleSessionUpdate(key: String, params: JSONValue) { + guard let p = params.objectValue, + let update = p["update"]?.objectValue, + let kind = update["sessionUpdate"]?.stringValue, + let continuation = sessions[key]?.continuation else { + logger.warning("[ACP] session/update missing fields or no continuation key=\(key, 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)") + 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)") + continuation.yield(.unknown(Self.thinkingDeltaFrame(text))) + } + + case "plan": + let entries = update["entries"]?.arrayValue?.count ?? 0 + logger.info("[ACP] ⟵ plan entries=\(entries)") + handlePlanUpdate(key: key, update: update, continuation: continuation) + + case "tool_call": + handleToolCall(key: key, update: update, continuation: continuation) + + case "tool_call_update": + handleToolCallUpdate(key: key, update: update, continuation: continuation) + + default: + logger.warning("[ACP] ⟵ unhandled sessionUpdate kind: \(kind, privacy: .public)") + } + } + + func handlePlanUpdate(key: String, update: [String: JSONValue], + continuation: AsyncStream.Continuation) { + guard let entries = update["entries"]?.arrayValue else { return } + + 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 = sessions[key]?.planSyntheticId ?? "acp-plan" + continuation.yield(.assistant(AssistantMessage( + role: "assistant", + content: [.toolUse( + id: planId, + name: "ExitPlanMode", + input: ["plan": .string(markdown)] + )] + ))) + mutateSession(key) { $0.planEmitted = true } + } + + func handleToolCall(key: String, 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 kind = update["kind"]?.stringValue + let rawInput = update["rawInput"]?.objectValue ?? [:] + let normalized = Self.normalizeToolCall(kind: kind, title: title, update: update, rawInput: rawInput) + let mcpTag = (normalized.name.contains("context7") || normalized.name.contains("mcp_") || title.contains("MCP")) ? " [MCP]" : "" + logger.info("[ACP] ⟵ tool_call\(mcpTag, privacy: .public) id=\(toolCallId, privacy: .public) name=\(normalized.name, privacy: .public) title=\(title, privacy: .public) kind=\(kind ?? "", privacy: .public) inputKeys=[\(normalized.input.keys.sorted().joined(separator: ","), privacy: .public)]") + + mutateSession(key) { $0.liveToolCalls.insert(toolCallId) } + + continuation.yield(.assistant(AssistantMessage( + role: "assistant", + content: [.toolUse(id: toolCallId, name: normalized.name, input: normalized.input)] + ))) + } + + func handleToolCallUpdate(key: String, 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)") + + // If the update carries diff content (ACP allows the agent to attach + // diffs on either tool_call or tool_call_update), re-emit a toolUse + // with the merged input so AppState can persist the file edit when the + // result lands. AppState merges by toolCallId, so this patches the + // existing block in place rather than appending a duplicate. + let diffEntries = Self.diffEntries(in: update) + if !diffEntries.isEmpty { + let kind = update["kind"]?.stringValue + let title = update["title"]?.stringValue ?? kind ?? "tool" + let rawInput = update["rawInput"]?.objectValue ?? [:] + let normalized = Self.normalizeToolCall(kind: kind ?? "edit", title: title, update: update, rawInput: rawInput) + logger.info("[ACP] ⟵ tool_call_update id=\(toolCallId, privacy: .public) carrying diffs=\(diffEntries.count) → patching toolUse name=\(normalized.name, privacy: .public)") + continuation.yield(.assistant(AssistantMessage( + role: "assistant", + content: [.toolUse(id: toolCallId, name: normalized.name, input: normalized.input)] + ))) + } + + // 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) + + func handleAgentRequest(key: String, id: JSONValue, method: String, params: JSONValue) async { + logger.info("[ACP] ⟵ agent-request \(method, privacy: .public) key=\(key, privacy: .public)") + switch method { + case "fs/read_text_file": + await handleFsReadTextFile(key: key, id: id, params: params) + case "fs/write_text_file": + await handleFsWriteTextFile(key: key, id: id, params: params) + case "session/request_permission": + await handleSessionRequestPermission(key: key, id: id, params: params) + default: + logger.warning("[ACP] unsupported agent-request: \(method, privacy: .public)") + sendError(key: key, id: id, code: -32601, message: "Method not supported: \(method)") + } + } + + func handleFsReadTextFile(key: String, id: JSONValue, params: JSONValue) async { + guard let path = params.objectValue?["path"]?.stringValue else { + sendError(key: key, 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(key: key, id: id, result: .object(["content": .string(content)])) + } catch { + sendError(key: key, id: id, code: -32000, message: error.localizedDescription) + } + } + + func handleFsWriteTextFile(key: String, id: JSONValue, params: JSONValue) async { + guard let path = params.objectValue?["path"]?.stringValue, + let content = params.objectValue?["content"]?.stringValue else { + sendError(key: key, id: id, code: -32602, message: "Missing path or content") + return + } + do { + try Self.writeTextFile(path: path, content: content) + sendResult(key: key, id: id, result: .object([:])) + } catch { + sendError(key: key, id: id, code: -32000, message: error.localizedDescription) + } + } + + func handleSessionRequestPermission(key: String, id: JSONValue, params: JSONValue) async { + guard let entry = sessions[key] else { + logger.warning("[ACP] permission request arrived for closed session") + sendError(key: key, id: id, code: -32000, message: "Session 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 { + 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(key: key, 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)") + + 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(key: key, id: id, result: .object([ + "outcome": .object([ + "outcome": .string("selected"), + "optionId": .string(optionId) + ]) + ])) + } +} diff --git a/RxCode/Services/ACPService+Spawn.swift b/RxCode/Services/ACPService+Spawn.swift new file mode 100644 index 0000000..cef38d6 --- /dev/null +++ b/RxCode/Services/ACPService+Spawn.swift @@ -0,0 +1,101 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - Process Spawn + +extension ACPService { + + 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) + } + + 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 + } + + 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 + } + + func resolveLaunch(_ launch: ACPClientSpec.LaunchKind) + throws -> (String, [String], [String: String]) + { + switch launch { + case .npx(let package, let args, let env): + 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) + } + } +} diff --git a/RxCode/Services/ACPService+Turn.swift b/RxCode/Services/ACPService+Turn.swift new file mode 100644 index 0000000..a276304 --- /dev/null +++ b/RxCode/Services/ACPService+Turn.swift @@ -0,0 +1,514 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - Turn Lifecycle & Model Probing + +extension ACPService { + + // MARK: - Public Interface + + func send( + streamId: UUID, + prompt: String, + cwd: String, + sessionId: String?, + model: String?, + spec: ACPClientSpec, + permissionMode: PermissionMode, + clientSessionKey: String, + mcpServers: [JSONValue] = [] + ) -> 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) clientKey=\(clientSessionKey, privacy: .public) mcpServers=\(mcpServers.count) 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, + mcpServers: mcpServers, + continuation: continuation + ) + } + continuation.onTermination = { _ in + task.cancel() + Task { await self.handleStreamTermination(streamId: streamId) } + } + } + } + + func handleStreamTermination(streamId: UUID) { + logger.info("[ACP] stream consumer detached streamId=\(streamId.uuidString, privacy: .public)") + finalize(streamId: streamId) + } + + /// 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() + let key = "probe-\(streamId.uuidString)" + 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)") + + var entry = SessionEntry( + process: process, + stdin: stdin, + spec: spec, + cwd: cwd, + canonicalKey: key, + isEphemeral: true + ) + entry.currentStreamId = streamId + sessions[key] = entry + streamToKey[streamId] = key + + startStderrReader(key: key, stderr: stderr) + let readerTask = Self.spawnStdoutReader(key: key, stdout: stdout, service: self) + mutateSession(key) { $0.stdoutReaderTask = readerTask } + process.terminationHandler = { [weak self] _ in + Task.detached { await self?.handleProcessExit(key: key) } + } + + do { + let result = try await withThrowingTaskGroup(of: ACPModelConfig?.self) { group in + group.addTask { + try await self.runProbeSequence(key: key, 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() + killSession(key: key) + streamToKey.removeValue(forKey: streamId) + return result + } catch { + let stderrSnapshot = sessions[key]?.stderr.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !stderrSnapshot.isEmpty { + logger.error("[ACP] probe stderr for \(spec.displayName, privacy: .public): \(stderrSnapshot, privacy: .public)") + } + readerTask.cancel() + killSession(key: key) + streamToKey.removeValue(forKey: streamId) + throw error + } + } + + func runProbeSequence( + key: String, + spec: ACPClientSpec, + cwd: String + ) async throws -> ACPModelConfig? { + logger.info("[ACP] probe → initialize \(spec.displayName, privacy: .public)") + _ = try await sendRequest( + key: key, + 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( + key: key, + 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 + + func runTurn( + streamId: UUID, + prompt: String, + cwd: String, + incomingSessionId: String?, + model: String?, + spec: ACPClientSpec, + permissionMode: PermissionMode, + clientSessionKey: String, + mcpServers: [JSONValue] = [], + continuation: AsyncStream.Continuation + ) async { + do { + // Resolve the pool key — AppState may pass the original + // `pending-…` key OR the agent's later sessionId. Either should + // map to the same pooled entry. + let resolvedKey = aliasToCanonical[clientSessionKey] ?? clientSessionKey + let canReuse: Bool = { + guard let existing = sessions[resolvedKey] else { return false } + guard !existing.isEphemeral else { return false } + guard existing.spec.id == spec.id else { return false } + guard existing.cwd == cwd else { return false } + guard existing.process.isRunning else { return false } + guard existing.agentSessionId != nil else { return false } + return true + }() + + if canReuse { + try await runReusedTurn( + streamId: streamId, + poolKey: resolvedKey, + prompt: prompt, + model: model, + continuation: continuation + ) + return + } + + // Pool has a stale entry (different spec/cwd or process dead). + if sessions[resolvedKey] != nil { + logger.info("[ACP] dropping stale pooled session for key=\(resolvedKey, privacy: .public)") + killSession(key: resolvedKey) + } + + try await runFreshTurn( + streamId: streamId, + bootstrapKey: clientSessionKey, + prompt: prompt, + cwd: cwd, + incomingSessionId: incomingSessionId, + model: model, + spec: spec, + permissionMode: permissionMode, + mcpServers: mcpServers, + continuation: continuation + ) + } 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) + } + } + + func runFreshTurn( + streamId: UUID, + bootstrapKey: String, + prompt: String, + cwd: String, + incomingSessionId: String?, + model: String?, + spec: ACPClientSpec, + permissionMode: PermissionMode, + mcpServers: [JSONValue] = [], + continuation: AsyncStream.Continuation + ) async throws { + let (process, stdin, stdout, stderr) = try await spawn(spec: spec, model: model, cwd: cwd) + var entry = SessionEntry( + process: process, + stdin: stdin, + spec: spec, + cwd: cwd, + canonicalKey: bootstrapKey, + isEphemeral: false + ) + entry.continuation = continuation + entry.currentStreamId = streamId + sessions[bootstrapKey] = entry + streamToKey[streamId] = bootstrapKey + + // Reader closures capture the canonical key, not the streamId, so + // the readers keep delivering correctly across turns when the pool + // entry is later re-keyed to the agent's sessionId. + startStderrReader(key: bootstrapKey, stderr: stderr) + let readerTask = Self.spawnStdoutReader(key: bootstrapKey, stdout: stdout, service: self) + mutateSession(bootstrapKey) { $0.stdoutReaderTask = readerTask } + process.terminationHandler = { [weak self] _ in + // Use the canonical key at exit time — `handleProcessExit` looks + // up via the alias map, so this works even if the pool entry has + // since been re-keyed to the agent's sessionId. + Task.detached { await self?.handleProcessExit(key: bootstrapKey) } + } + + 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( + key: bootstrapKey, + 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)") + logger.info("[ACP-MCP] runFreshTurn received \(mcpServers.count, privacy: .public) candidate mcpServers from caller") + let filteredMCPServers = supportedMCPServers(mcpServers, initResult: initResult) + if let data = try? JSONEncoder().encode(JSONValue.array(filteredMCPServers)), + let json = String(data: data, encoding: .utf8) { + logger.info("[ACP-MCP] session/new mcpServers payload=\(json, privacy: .public)") + } + + continuation.yield(.system(SystemEvent( + subtype: "init", + sessionId: incomingSessionId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + + // 2. session/new — fresh ACP session backed by the new process. The + // agent's history will accumulate within this process across pooled + // turns; we don't use `session/load` because that re-emits the + // persisted conversation as updates and would duplicate messages. + let newParams: [String: JSONValue] = [ + "cwd": .string(cwd), + "mcpServers": .array(filteredMCPServers) + ] + let newResult = try await sendRequest(key: bootstrapKey, method: "session/new", params: newParams) + guard let agentSessionId = newResult.objectValue?["sessionId"]?.stringValue else { + throw ACPError.protocolMismatch("session/new returned no sessionId") + } + let modelConfig = Self.parseModelConfig(from: newResult) + _ = incomingSessionId + + // Re-key the pool entry to the agent's sessionId so subsequent turns + // (where AppState's sessionKey is the agent sid) hit the cache. + let canonicalKey = agentSessionId + if canonicalKey != bootstrapKey { + if var moved = sessions.removeValue(forKey: bootstrapKey) { + moved.canonicalKey = canonicalKey + moved.agentSessionId = agentSessionId + moved.modelConfigId = modelConfig?.configId + sessions[canonicalKey] = moved + } + streamToKey[streamId] = canonicalKey + aliasToCanonical[bootstrapKey] = canonicalKey + } else { + mutateSession(canonicalKey) { + $0.agentSessionId = agentSessionId + $0.modelConfigId = modelConfig?.configId + } + } + + if let modelConfig { + logger.info("[ACP] discovered model selector for \(spec.displayName, 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)") + } + + try await applyModelSelection( + key: canonicalKey, + agentSessionId: agentSessionId, + modelConfig: modelConfig, + model: model + ) + + continuation.yield(.system(SystemEvent( + subtype: "session_started", + sessionId: agentSessionId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + + try await runPrompt( + key: canonicalKey, + agentSessionId: agentSessionId, + prompt: prompt, + continuation: continuation + ) + } + + func runReusedTurn( + streamId: UUID, + poolKey: String, + prompt: String, + model: String?, + continuation: AsyncStream.Continuation + ) async throws { + guard let agentSessionId = sessions[poolKey]?.agentSessionId else { + throw ACPError.protocolMismatch("pooled session missing agentSessionId") + } + let modelConfigId = sessions[poolKey]?.modelConfigId + let spec = sessions[poolKey]!.spec + logger.info("[ACP] reusing pooled session key=\(poolKey, privacy: .public) agentSid=\(agentSessionId, privacy: .public) client=\(spec.displayName, privacy: .public)") + logger.info("[ACP-MCP] reused turn — NOT re-sending mcpServers; agent already has whatever was registered at initial session/new") + + // Reset per-turn state and adopt the new streamId/continuation. + mutateSession(poolKey) { e in + e.currentStreamId = streamId + e.continuation = continuation + e.liveToolCalls.removeAll() + e.planEmitted = false + e.planSyntheticId = "acp-plan-\(UUID().uuidString)" + e.acceptingUpdates = false + } + streamToKey[streamId] = poolKey + + continuation.yield(.system(SystemEvent( + subtype: "init", + sessionId: agentSessionId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + + // Apply model change if the user picked a different one for this turn. + if let modelConfigId, let model, !model.isEmpty { + let cfg = ACPModelConfig(configId: modelConfigId, currentValue: nil, options: [ + ACPModelOption(value: model, name: model, description: nil) + ]) + try await applyModelSelection( + key: poolKey, + agentSessionId: agentSessionId, + modelConfig: cfg, + model: model + ) + } + + continuation.yield(.system(SystemEvent( + subtype: "session_started", + sessionId: agentSessionId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + + try await runPrompt( + key: poolKey, + agentSessionId: agentSessionId, + prompt: prompt, + continuation: continuation + ) + } + + /// Issues `session/set_config_option` to switch the agent's active model + /// when the user picked a different value than the one currently selected. + /// Best-effort: failures are logged but don't abort the turn. + func applyModelSelection( + key: String, + agentSessionId: String, + modelConfig: ACPModelConfig?, + model: String? + ) async throws { + guard let modelConfig, + let model, + !model.isEmpty, + modelConfig.options.contains(where: { $0.value == model }), + modelConfig.currentValue != model + else { return } + do { + _ = try await sendRequest( + key: key, + 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)") + } + } + + func runPrompt( + key: String, + agentSessionId: String, + prompt: String, + continuation: AsyncStream.Continuation + ) async throws { + // Flip the gate AFTER any optional `session/set_config_option` + // exchange so we don't accept stray updates before the prompt is + // actually in flight. + mutateSession(key) { $0.acceptingUpdates = true } + let promptResult = try await sendRequest( + key: key, + 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 + ))) + + continuation.finish() + if let streamId = sessions[key]?.currentStreamId { + finalize(streamId: streamId) + } + } + + 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)") + } +} diff --git a/RxCode/Services/ACPService.swift b/RxCode/Services/ACPService.swift index a81cd2a..4933a15 100644 --- a/RxCode/Services/ACPService.swift +++ b/RxCode/Services/ACPService.swift @@ -15,14 +15,18 @@ import os // key (initially the AppState `clientSessionKey`, re-keyed to the agent's // `session/new` sessionId once known) so subsequent turns whose key has been // renamed in AppState still find the same process. +// +// This file holds the actor declaration, stored state, and pool lifecycle. +// Turn running, process spawning, JSON-RPC framing, and helpers live in the +// `ACPService+*.swift` extensions. actor ACPService { - private let logger = Logger(subsystem: "com.claudework", category: "ACPService") + let logger = Logger(subsystem: "com.claudework", category: "ACPService") /// Per-pool entry. Persistent fields outlive a single turn; per-turn /// fields are reset before each `session/prompt`. - private struct SessionEntry { + struct SessionEntry { // Persistent (process-level) let process: Process let stdin: FileHandle @@ -52,22 +56,22 @@ actor ACPService { let isEphemeral: Bool } - private var sessions: [String: SessionEntry] = [:] + var sessions: [String: SessionEntry] = [:] /// Maps an externally-issued streamId to its canonical pool key. - private var streamToKey: [UUID: String] = [:] + var streamToKey: [UUID: String] = [:] /// Maps a previously-seen pool key (e.g. AppState's "pending-…" key) to /// the canonical key (the agent's `session/new` sessionId). Used to /// resolve subsequent turns whose `clientSessionKey` was renamed by /// AppState's session-id reconciliation. - private var aliasToCanonical: [String: String] = [:] + var aliasToCanonical: [String: String] = [:] /// Reference to the permission server for bridging `session/request_permission`. - private weak var permissionServer: PermissionServer? + 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? + var cachedShellPath: String? init() {} @@ -75,41 +79,7 @@ actor ACPService { self.permissionServer = server } - // MARK: - Public Interface - - func send( - streamId: UUID, - prompt: String, - cwd: String, - sessionId: String?, - model: String?, - spec: ACPClientSpec, - permissionMode: PermissionMode, - clientSessionKey: String, - mcpServers: [JSONValue] = [] - ) -> 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) clientKey=\(clientSessionKey, privacy: .public) mcpServers=\(mcpServers.count) 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, - mcpServers: mcpServers, - continuation: continuation - ) - } - continuation.onTermination = { _ in - task.cancel() - Task { await self.handleStreamTermination(streamId: streamId) } - } - } - } + // MARK: - Pool Lifecycle /// Releases per-stream bookkeeping for a turn that ran to completion. /// The pooled agent process stays alive so the next turn for the same @@ -137,11 +107,6 @@ actor ACPService { logger.info("[ACP] finalize streamId=\(streamId.uuidString, privacy: .public) key=\(key, privacy: .public) keepingProcess=\(!entry.isEphemeral) running=\(entry.process.isRunning)") } - func handleStreamTermination(streamId: UUID) { - logger.info("[ACP] stream consumer detached streamId=\(streamId.uuidString, privacy: .public)") - finalize(streamId: streamId) - } - /// Cancels the in-flight turn for `streamId` via `session/cancel` but /// keeps the agent process alive so the user can immediately send a new /// prompt without losing conversation state. @@ -192,7 +157,7 @@ actor ACPService { /// Tear down a pooled session — terminate the process and forget all /// references. Used by `cleanup()`, ephemeral probe sessions, and as a /// recovery path when a pooled process dies between turns. - private func killSession(key: String) { + func killSession(key: String) { guard var entry = sessions.removeValue(forKey: key) else { return } try? entry.stdin.close() if entry.process.isRunning { @@ -213,1250 +178,9 @@ actor ACPService { for alias in aliases { aliasToCanonical.removeValue(forKey: alias) } } - /// 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() - let key = "probe-\(streamId.uuidString)" - 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)") - - var entry = SessionEntry( - process: process, - stdin: stdin, - spec: spec, - cwd: cwd, - canonicalKey: key, - isEphemeral: true - ) - entry.currentStreamId = streamId - sessions[key] = entry - streamToKey[streamId] = key - - startStderrReader(key: key, stderr: stderr) - let readerTask = Self.spawnStdoutReader(key: key, stdout: stdout, service: self) - mutateSession(key) { $0.stdoutReaderTask = readerTask } - process.terminationHandler = { [weak self] _ in - Task.detached { await self?.handleProcessExit(key: key) } - } - - do { - let result = try await withThrowingTaskGroup(of: ACPModelConfig?.self) { group in - group.addTask { - try await self.runProbeSequence(key: key, 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() - killSession(key: key) - streamToKey.removeValue(forKey: streamId) - return result - } catch { - let stderrSnapshot = sessions[key]?.stderr.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !stderrSnapshot.isEmpty { - logger.error("[ACP] probe stderr for \(spec.displayName, privacy: .public): \(stderrSnapshot, privacy: .public)") - } - readerTask.cancel() - killSession(key: key) - streamToKey.removeValue(forKey: streamId) - throw error - } - } - - private func runProbeSequence( - key: String, - spec: ACPClientSpec, - cwd: String - ) async throws -> ACPModelConfig? { - logger.info("[ACP] probe → initialize \(spec.displayName, privacy: .public)") - _ = try await sendRequest( - key: key, - 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( - key: key, - 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, - mcpServers: [JSONValue] = [], - continuation: AsyncStream.Continuation - ) async { - do { - // Resolve the pool key — AppState may pass the original - // `pending-…` key OR the agent's later sessionId. Either should - // map to the same pooled entry. - let resolvedKey = aliasToCanonical[clientSessionKey] ?? clientSessionKey - let canReuse: Bool = { - guard let existing = sessions[resolvedKey] else { return false } - guard !existing.isEphemeral else { return false } - guard existing.spec.id == spec.id else { return false } - guard existing.cwd == cwd else { return false } - guard existing.process.isRunning else { return false } - guard existing.agentSessionId != nil else { return false } - return true - }() - - if canReuse { - try await runReusedTurn( - streamId: streamId, - poolKey: resolvedKey, - prompt: prompt, - model: model, - continuation: continuation - ) - return - } - - // Pool has a stale entry (different spec/cwd or process dead). - if sessions[resolvedKey] != nil { - logger.info("[ACP] dropping stale pooled session for key=\(resolvedKey, privacy: .public)") - killSession(key: resolvedKey) - } - - try await runFreshTurn( - streamId: streamId, - bootstrapKey: clientSessionKey, - prompt: prompt, - cwd: cwd, - incomingSessionId: incomingSessionId, - model: model, - spec: spec, - permissionMode: permissionMode, - mcpServers: mcpServers, - continuation: continuation - ) - } 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 runFreshTurn( - streamId: UUID, - bootstrapKey: String, - prompt: String, - cwd: String, - incomingSessionId: String?, - model: String?, - spec: ACPClientSpec, - permissionMode: PermissionMode, - mcpServers: [JSONValue] = [], - continuation: AsyncStream.Continuation - ) async throws { - let (process, stdin, stdout, stderr) = try await spawn(spec: spec, model: model, cwd: cwd) - var entry = SessionEntry( - process: process, - stdin: stdin, - spec: spec, - cwd: cwd, - canonicalKey: bootstrapKey, - isEphemeral: false - ) - entry.continuation = continuation - entry.currentStreamId = streamId - sessions[bootstrapKey] = entry - streamToKey[streamId] = bootstrapKey - - // Reader closures capture the canonical key, not the streamId, so - // the readers keep delivering correctly across turns when the pool - // entry is later re-keyed to the agent's sessionId. - startStderrReader(key: bootstrapKey, stderr: stderr) - let readerTask = Self.spawnStdoutReader(key: bootstrapKey, stdout: stdout, service: self) - mutateSession(bootstrapKey) { $0.stdoutReaderTask = readerTask } - process.terminationHandler = { [weak self] _ in - // Use the canonical key at exit time — `handleProcessExit` looks - // up via the alias map, so this works even if the pool entry has - // since been re-keyed to the agent's sessionId. - Task.detached { await self?.handleProcessExit(key: bootstrapKey) } - } - - 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( - key: bootstrapKey, - 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)") - logger.info("[ACP-MCP] runFreshTurn received \(mcpServers.count, privacy: .public) candidate mcpServers from caller") - let filteredMCPServers = supportedMCPServers(mcpServers, initResult: initResult) - if let data = try? JSONEncoder().encode(JSONValue.array(filteredMCPServers)), - let json = String(data: data, encoding: .utf8) { - logger.info("[ACP-MCP] session/new mcpServers payload=\(json, privacy: .public)") - } - - continuation.yield(.system(SystemEvent( - subtype: "init", - sessionId: incomingSessionId, - tools: nil, - model: model, - claudeCodeVersion: nil - ))) - - // 2. session/new — fresh ACP session backed by the new process. The - // agent's history will accumulate within this process across pooled - // turns; we don't use `session/load` because that re-emits the - // persisted conversation as updates and would duplicate messages. - let newParams: [String: JSONValue] = [ - "cwd": .string(cwd), - "mcpServers": .array(filteredMCPServers) - ] - let newResult = try await sendRequest(key: bootstrapKey, method: "session/new", params: newParams) - guard let agentSessionId = newResult.objectValue?["sessionId"]?.stringValue else { - throw ACPError.protocolMismatch("session/new returned no sessionId") - } - let modelConfig = Self.parseModelConfig(from: newResult) - _ = incomingSessionId - - // Re-key the pool entry to the agent's sessionId so subsequent turns - // (where AppState's sessionKey is the agent sid) hit the cache. - let canonicalKey = agentSessionId - if canonicalKey != bootstrapKey { - if var moved = sessions.removeValue(forKey: bootstrapKey) { - moved.canonicalKey = canonicalKey - moved.agentSessionId = agentSessionId - moved.modelConfigId = modelConfig?.configId - sessions[canonicalKey] = moved - } - streamToKey[streamId] = canonicalKey - aliasToCanonical[bootstrapKey] = canonicalKey - } else { - mutateSession(canonicalKey) { - $0.agentSessionId = agentSessionId - $0.modelConfigId = modelConfig?.configId - } - } - - if let modelConfig { - logger.info("[ACP] discovered model selector for \(spec.displayName, 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)") - } - - try await applyModelSelection( - key: canonicalKey, - agentSessionId: agentSessionId, - modelConfig: modelConfig, - model: model - ) - - continuation.yield(.system(SystemEvent( - subtype: "session_started", - sessionId: agentSessionId, - tools: nil, - model: model, - claudeCodeVersion: nil - ))) - - try await runPrompt( - key: canonicalKey, - agentSessionId: agentSessionId, - prompt: prompt, - continuation: continuation - ) - } - - private func runReusedTurn( - streamId: UUID, - poolKey: String, - prompt: String, - model: String?, - continuation: AsyncStream.Continuation - ) async throws { - guard let agentSessionId = sessions[poolKey]?.agentSessionId else { - throw ACPError.protocolMismatch("pooled session missing agentSessionId") - } - let modelConfigId = sessions[poolKey]?.modelConfigId - let spec = sessions[poolKey]!.spec - logger.info("[ACP] reusing pooled session key=\(poolKey, privacy: .public) agentSid=\(agentSessionId, privacy: .public) client=\(spec.displayName, privacy: .public)") - logger.info("[ACP-MCP] reused turn — NOT re-sending mcpServers; agent already has whatever was registered at initial session/new") - - // Reset per-turn state and adopt the new streamId/continuation. - mutateSession(poolKey) { e in - e.currentStreamId = streamId - e.continuation = continuation - e.liveToolCalls.removeAll() - e.planEmitted = false - e.planSyntheticId = "acp-plan-\(UUID().uuidString)" - e.acceptingUpdates = false - } - streamToKey[streamId] = poolKey - - continuation.yield(.system(SystemEvent( - subtype: "init", - sessionId: agentSessionId, - tools: nil, - model: model, - claudeCodeVersion: nil - ))) - - // Apply model change if the user picked a different one for this turn. - if let modelConfigId, let model, !model.isEmpty { - let cfg = ACPModelConfig(configId: modelConfigId, currentValue: nil, options: [ - ACPModelOption(value: model, name: model, description: nil) - ]) - try await applyModelSelection( - key: poolKey, - agentSessionId: agentSessionId, - modelConfig: cfg, - model: model - ) - } - - continuation.yield(.system(SystemEvent( - subtype: "session_started", - sessionId: agentSessionId, - tools: nil, - model: model, - claudeCodeVersion: nil - ))) - - try await runPrompt( - key: poolKey, - agentSessionId: agentSessionId, - prompt: prompt, - continuation: continuation - ) - } - - /// Issues `session/set_config_option` to switch the agent's active model - /// when the user picked a different value than the one currently selected. - /// Best-effort: failures are logged but don't abort the turn. - private func applyModelSelection( - key: String, - agentSessionId: String, - modelConfig: ACPModelConfig?, - model: String? - ) async throws { - guard let modelConfig, - let model, - !model.isEmpty, - modelConfig.options.contains(where: { $0.value == model }), - modelConfig.currentValue != model - else { return } - do { - _ = try await sendRequest( - key: key, - 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)") - } - } - - private func runPrompt( - key: String, - agentSessionId: String, - prompt: String, - continuation: AsyncStream.Continuation - ) async throws { - // Flip the gate AFTER any optional `session/set_config_option` - // exchange so we don't accept stray updates before the prompt is - // actually in flight. - mutateSession(key) { $0.acceptingUpdates = true } - let promptResult = try await sendRequest( - key: key, - 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 - ))) - - continuation.finish() - if let streamId = sessions[key]?.currentStreamId { - 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): - 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(key: String, method: String, params: [String: JSONValue]) - async throws -> JSONValue - { - guard sessions[key] != nil else { throw ACPError.streamClosed } - - let id = nextRequestId(key: key) - let frame: [String: JSONValue] = [ - "jsonrpc": .string("2.0"), - "id": .number(Double(id)), - "method": .string(method), - "params": .object(params) - ] - try writeFrame(key: key, frame: .object(frame)) - logger.info("[ACP] → \(method, privacy: .public) id=\(id) key=\(key, privacy: .public)") - - return try await withCheckedThrowingContinuation { cont in - mutateSession(key) { $0.pending[id] = cont } - } - } - - private func sendResult(key: String, id: JSONValue, result: JSONValue) { - let frame: [String: JSONValue] = [ - "jsonrpc": .string("2.0"), - "id": id, - "result": result - ] - try? writeFrame(key: key, frame: .object(frame)) - } - - private func sendError(key: String, 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(key: key, frame: .object(frame)) - } - - private func writeFrame(key: String, frame: JSONValue) throws { - guard let entry = sessions[key] else { throw ACPError.streamClosed } - let data = try JSONEncoder().encode(frame) - var line = data - line.append(0x0A) - try entry.stdin.write(contentsOf: line) - } - - private func nextRequestId(key: String) -> Int { - var id = 0 - mutateSession(key) { entry in - id = entry.nextId - entry.nextId += 1 - } - return id - } - - private func mutateSession(_ key: String, _ mutate: (inout SessionEntry) -> Void) { - guard var entry = sessions[key] else { return } - mutate(&entry) - sessions[key] = 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( - key: String, - 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 - await service?.logReaderStarted(key: key) - var buffer = Data() - for await chunk in chunkStream { - if Task.isCancelled { - await service?.logReaderCancelled(key: key) - 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(key: key, 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"] { - await handleAgentRequest(key: key, id: idVal, method: method, params: params) - } else { - handleAgentNotification(key: key, method: method, params: params) - } - } - - private func resolveResponse(key: String, id: Int, body: [String: JSONValue]) { - var cont: CheckedContinuation? - mutateSession(key) { entry in - cont = entry.pending.removeValue(forKey: id) - } - guard let cont else { - logger.warning("[ACP] ← response id=\(id) had no pending continuation key=\(key, 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(key: String, method: String, params: JSONValue) { - switch method { - case "session/update": - guard sessions[key]?.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(key: key, params: params) - default: - logger.warning("[ACP] ⟵ unknown notification: \(method, privacy: .public)") - } - } - - private func handleSessionUpdate(key: String, params: JSONValue) { - guard let p = params.objectValue, - let update = p["update"]?.objectValue, - let kind = update["sessionUpdate"]?.stringValue, - let continuation = sessions[key]?.continuation else { - logger.warning("[ACP] session/update missing fields or no continuation key=\(key, 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)") - 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)") - continuation.yield(.unknown(Self.thinkingDeltaFrame(text))) - } - - case "plan": - let entries = update["entries"]?.arrayValue?.count ?? 0 - logger.info("[ACP] ⟵ plan entries=\(entries)") - handlePlanUpdate(key: key, update: update, continuation: continuation) - - case "tool_call": - handleToolCall(key: key, update: update, continuation: continuation) - - case "tool_call_update": - handleToolCallUpdate(key: key, update: update, continuation: continuation) - - default: - logger.warning("[ACP] ⟵ unhandled sessionUpdate kind: \(kind, privacy: .public)") - } - } - - private func handlePlanUpdate(key: String, update: [String: JSONValue], - continuation: AsyncStream.Continuation) { - guard let entries = update["entries"]?.arrayValue else { return } - - 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 = sessions[key]?.planSyntheticId ?? "acp-plan" - continuation.yield(.assistant(AssistantMessage( - role: "assistant", - content: [.toolUse( - id: planId, - name: "ExitPlanMode", - input: ["plan": .string(markdown)] - )] - ))) - mutateSession(key) { $0.planEmitted = true } - } - - private func handleToolCall(key: String, 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 kind = update["kind"]?.stringValue - let rawInput = update["rawInput"]?.objectValue ?? [:] - let normalized = Self.normalizeToolCall(kind: kind, title: title, update: update, rawInput: rawInput) - let mcpTag = (normalized.name.contains("context7") || normalized.name.contains("mcp_") || title.contains("MCP")) ? " [MCP]" : "" - logger.info("[ACP] ⟵ tool_call\(mcpTag, privacy: .public) id=\(toolCallId, privacy: .public) name=\(normalized.name, privacy: .public) title=\(title, privacy: .public) kind=\(kind ?? "", privacy: .public) inputKeys=[\(normalized.input.keys.sorted().joined(separator: ","), privacy: .public)]") - - mutateSession(key) { $0.liveToolCalls.insert(toolCallId) } - - continuation.yield(.assistant(AssistantMessage( - role: "assistant", - content: [.toolUse(id: toolCallId, name: normalized.name, input: normalized.input)] - ))) - } - - private func handleToolCallUpdate(key: String, 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)") - - // If the update carries diff content (ACP allows the agent to attach - // diffs on either tool_call or tool_call_update), re-emit a toolUse - // with the merged input so AppState can persist the file edit when the - // result lands. AppState merges by toolCallId, so this patches the - // existing block in place rather than appending a duplicate. - let diffEntries = Self.diffEntries(in: update) - if !diffEntries.isEmpty { - let kind = update["kind"]?.stringValue - let title = update["title"]?.stringValue ?? kind ?? "tool" - let rawInput = update["rawInput"]?.objectValue ?? [:] - let normalized = Self.normalizeToolCall(kind: kind ?? "edit", title: title, update: update, rawInput: rawInput) - logger.info("[ACP] ⟵ tool_call_update id=\(toolCallId, privacy: .public) carrying diffs=\(diffEntries.count) → patching toolUse name=\(normalized.name, privacy: .public)") - continuation.yield(.assistant(AssistantMessage( - role: "assistant", - content: [.toolUse(id: toolCallId, name: normalized.name, input: normalized.input)] - ))) - } - - // 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(key: String, id: JSONValue, method: String, params: JSONValue) async { - logger.info("[ACP] ⟵ agent-request \(method, privacy: .public) key=\(key, privacy: .public)") - switch method { - case "fs/read_text_file": - await handleFsReadTextFile(key: key, id: id, params: params) - case "fs/write_text_file": - await handleFsWriteTextFile(key: key, id: id, params: params) - case "session/request_permission": - await handleSessionRequestPermission(key: key, id: id, params: params) - default: - logger.warning("[ACP] unsupported agent-request: \(method, privacy: .public)") - sendError(key: key, id: id, code: -32601, message: "Method not supported: \(method)") - } - } - - private func handleFsReadTextFile(key: String, id: JSONValue, params: JSONValue) async { - guard let path = params.objectValue?["path"]?.stringValue else { - sendError(key: key, 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(key: key, id: id, result: .object(["content": .string(content)])) - } catch { - sendError(key: key, id: id, code: -32000, message: error.localizedDescription) - } - } - - private func handleFsWriteTextFile(key: String, id: JSONValue, params: JSONValue) async { - guard let path = params.objectValue?["path"]?.stringValue, - let content = params.objectValue?["content"]?.stringValue else { - sendError(key: key, id: id, code: -32602, message: "Missing path or content") - return - } - do { - try Self.writeTextFile(path: path, content: content) - sendResult(key: key, id: id, result: .object([:])) - } catch { - sendError(key: key, id: id, code: -32000, message: error.localizedDescription) - } - } - - private func handleSessionRequestPermission(key: String, id: JSONValue, params: JSONValue) async { - guard let entry = sessions[key] else { - logger.warning("[ACP] permission request arrived for closed session") - sendError(key: key, id: id, code: -32000, message: "Session 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 { - 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(key: key, 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)") - - 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(key: key, 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 - } - - private func agentSupportsMCPTransport(_ transport: String, initResult: JSONValue) -> Bool { - guard transport == "http" || transport == "sse" else { return true } - return initResult - .objectValue?["agentCapabilities"]? - .objectValue?["mcpCapabilities"]? - .objectValue?[transport]? - .boolValue ?? false - } - - private func supportedMCPServers(_ servers: [JSONValue], initResult: JSONValue) -> [JSONValue] { - let mcpCaps = initResult.objectValue?["agentCapabilities"]?.objectValue?["mcpCapabilities"] - logger.info("[ACP-MCP] agent advertised mcpCapabilities=\(mcpCaps?.shortDescription ?? "", privacy: .public) (http=\(mcpCaps?.objectValue?["http"]?.boolValue == true) sse=\(mcpCaps?.objectValue?["sse"]?.boolValue == true))") - - var supported: [JSONValue] = [] - var dropped: [String] = [] - - for server in servers { - let obj = server.objectValue - let transport = obj?["type"]?.stringValue ?? "stdio" - let name = obj?["name"]?.stringValue ?? "" - if agentSupportsMCPTransport(transport, initResult: initResult) { - logger.info("[ACP-MCP] passing entry name=\(name, privacy: .public) transport=\(transport, privacy: .public)") - supported.append(server) - } else { - logger.info("[ACP-MCP] dropping entry name=\(name, privacy: .public) transport=\(transport, privacy: .public) — agent doesn't advertise capability") - dropped.append("\(name):\(transport)") - } - } - - if !dropped.isEmpty { - logger.info("[ACP-MCP] dropping unsupported MCP transports: \(dropped.joined(separator: ", "), privacy: .public)") - } - logger.info("[ACP-MCP] passing \(supported.count, privacy: .public)/\(servers.count, privacy: .public) mcpServers after initialize capability check") - return supported - } - - /// 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 { - 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 - } - - // MARK: - Tool Call Normalization - // - // ACP `tool_call` notifications expose two views of a tool invocation: - // a machine-readable `kind` ("edit", "execute", "read", …) and a - // human-readable `title`. The agent's `rawInput` is opaque (each agent - // chooses its own schema), but the optional `content` array surfaces - // structured `diff` entries (`{path, oldText, newText}`) that we can - // translate into the Claude-shaped `Edit`/`Write`/`MultiEdit` input keys - // the rest of RxCode (sidebar persistence, diff renderer, plan card) - // already understands. - - private struct NormalizedToolCall { - let name: String - let input: [String: JSONValue] - } - - /// Extracts `{type: "diff", path, oldText, newText}` entries from a - /// session/update payload's `content` array. Returns `[]` for non-edit - /// kinds or absent content. - private static func diffEntries(in update: [String: JSONValue]) -> [(path: String, oldText: String?, newText: String)] { - guard let content = update["content"]?.arrayValue else { return [] } - var out: [(path: String, oldText: String?, newText: String)] = [] - for entry in content { - guard let obj = entry.objectValue, - obj["type"]?.stringValue == "diff", - let path = obj["path"]?.stringValue, - let newText = obj["newText"]?.stringValue - else { continue } - let oldText = obj["oldText"]?.stringValue - out.append((path: path, oldText: oldText, newText: newText)) - } - return out - } - - /// Translates an ACP tool_call payload into a Claude-shaped (name, input). - /// Edit-kind calls with diff content become `Edit`/`Write`/`MultiEdit` so - /// `ChatMessage.fileEditHunks` and `editedFilePath` can extract the path - /// and hunks for sidebar persistence. Other kinds fall back to the agent's - /// title + rawInput unchanged. - private static func normalizeToolCall( - kind: String?, - title: String, - update: [String: JSONValue], - rawInput: [String: JSONValue] - ) -> NormalizedToolCall { - let normalizedKind = (kind ?? "").lowercased() - let diffs = diffEntries(in: update) - if normalizedKind == "edit" || !diffs.isEmpty { - if !diffs.isEmpty { - if diffs.count == 1 { - let only = diffs[0] - let oldText = only.oldText ?? "" - if oldText.isEmpty { - return NormalizedToolCall( - name: "Write", - input: [ - "file_path": .string(only.path), - "content": .string(only.newText) - ] - ) - } - return NormalizedToolCall( - name: "Edit", - input: [ - "file_path": .string(only.path), - "old_string": .string(oldText), - "new_string": .string(only.newText) - ] - ) - } - let primaryPath = diffs.first!.path - let edits: [JSONValue] = diffs.filter { $0.path == primaryPath }.map { d in - .object([ - "old_string": .string(d.oldText ?? ""), - "new_string": .string(d.newText) - ]) - } - return NormalizedToolCall( - name: "MultiEdit", - input: [ - "file_path": .string(primaryPath), - "edits": .array(edits) - ] - ) - } - var input = rawInput - if input["file_path"] == nil, - let path = (update["locations"]?.arrayValue?.first?.objectValue?["path"]?.stringValue) { - input["file_path"] = .string(path) - } - return NormalizedToolCall(name: "Edit", input: input) - } - - return NormalizedToolCall(name: title, input: rawInput) - } - - /// 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(key: String, stderr: FileHandle) { + func startStderrReader(key: String, stderr: FileHandle) { let chunkStream = AsyncStream { continuation in stderr.readabilityHandler = { handle in let data = handle.availableData @@ -1490,7 +214,7 @@ actor ACPService { } } - private func appendStderr(key: String, line: String) { + func appendStderr(key: String, line: String) { let resolved = aliasToCanonical[key] ?? key mutateSession(resolved) { $0.stderr += line + "\n" } let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -1499,7 +223,7 @@ actor ACPService { } } - private func handleProcessExit(key: String) { + func handleProcessExit(key: String) { let resolved = aliasToCanonical[key] ?? key guard let entry = sessions[resolved] else { return } logger.info("[ACP] process exit pid=\(entry.process.processIdentifier) status=\(entry.process.terminationStatus) reason=\(String(describing: entry.process.terminationReason.rawValue), privacy: .public) pending=\(entry.pending.count) client=\(entry.spec.displayName, privacy: .public)") @@ -1534,74 +258,3 @@ enum ACPError: LocalizedError { } } } - -// MARK: - JSONValue Conveniences - -extension JSONValue { - fileprivate nonisolated var objectValue: [String: JSONValue]? { - if case .object(let o) = self { return o } - return nil - } - fileprivate nonisolated var arrayValue: [JSONValue]? { - if case .array(let a) = self { return a } - return nil - } - fileprivate nonisolated var stringValue: String? { - if case .string(let s) = self { return s } - return nil - } - fileprivate nonisolated var boolValue: Bool? { - if case .bool(let b) = self { return b } - return nil - } - fileprivate nonisolated var intValue: Int? { - if case .number(let n) = self { return Int(n) } - return nil - } - fileprivate nonisolated var shortDescription: String { - switch self { - case .object(let o): return "object(\(o.count) keys)" - case .array(let a): return "array(\(a.count))" - case .string(let s): return s.prefix(80).description - case .number(let n): return "\(n)" - case .bool(let b): return "\(b)" - case .null: return "null" - } - } -} - -// MARK: - AgentBackend Conformance - -extension ACPService: AgentBackend { - nonisolated var provider: AgentProvider { .acp } - nonisolated var staticCapabilities: CapabilitySet { AgentProvider.acp.staticCapabilities } - - func send(_ request: BackendSendRequest) -> AsyncStream { - guard let spec = request.acpSpec else { - return 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: request.sessionId ?? request.clientSessionKey, - isError: true, totalTurns: nil, usage: nil, contextWindow: nil - ))) - c.finish() - } - } - return send( - streamId: request.streamId, - prompt: request.prompt, - cwd: request.cwd, - sessionId: request.sessionId, - model: request.model, - spec: spec, - permissionMode: request.permissionMode, - clientSessionKey: request.clientSessionKey, - mcpServers: request.acpMCPServers - ) - } -} diff --git a/RxCode/Services/ClaudeService+Discovery.swift b/RxCode/Services/ClaudeService+Discovery.swift new file mode 100644 index 0000000..0c1458c --- /dev/null +++ b/RxCode/Services/ClaudeService+Discovery.swift @@ -0,0 +1,230 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - Discovery & Environment + +extension ClaudeCodeServer { + + // MARK: - Shell PATH Resolution + + /// Compose a PATH that lets the spawned `claude` CLI find `node` and + /// related tools regardless of where the user installed them. + /// + /// Combines, in priority order: + /// 1. The user's interactive login shell PATH (captures nvm/asdf/.zshrc init) + /// 2. Well-known tool directories (Homebrew, npm-global, nvm latest) + /// 3. The GUI process's existing PATH as a final fallback + func resolvedShellPath() async -> String { + if let cached = cachedShellPath { return cached } + + var paths: [String] = [] + var seen = Set() + func add(_ entry: String) { + let trimmed = entry.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return } + paths.append(trimmed) + } + + if let shellPath = await readUserShellPath() { + for component in shellPath.split(separator: ":") { add(String(component)) } + } + + let home = FileManager.default.homeDirectoryForCurrentUser.path + for dir in [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "\(home)/.local/bin", + "\(home)/.npm-global/bin", + ] { add(dir) } + + if let nvmBin = latestNvmBinDirectory(home: home) { add(nvmBin) } + + if let existing = ProcessInfo.processInfo.environment["PATH"] { + for component in existing.split(separator: ":") { add(String(component)) } + } + + // Double-check after awaits: another reentrant caller may have populated it. + if let cached = cachedShellPath { return cached } + + let combined = paths.joined(separator: ":") + cachedShellPath = combined + logger.info("Resolved shell PATH for subprocess (entries=\(paths.count))") + return combined + } + + /// Spawn the user's login shell once to read its `$PATH`. + /// Uses `-ilc` so `.zshrc` (and the nvm/asdf init it typically sources) runs. + func readUserShellPath() async -> String? { + do { + let output = try await runShellCommand( + "/bin/zsh", + arguments: ["-ilc", "print -rn -- $PATH"], + injectPath: false + ) + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } catch { + logger.warning("Failed to read user shell PATH: \(error.localizedDescription)") + return nil + } + } + + /// Locate the bin directory of the most recent nvm-installed Node, if any. + /// Defends against shell readout failure for nvm users. + func latestNvmBinDirectory(home: String) -> String? { + let root = "\(home)/.nvm/versions/node" + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return nil } + for entry in entries.sorted(by: >) { + let bin = "\(root)/\(entry)/bin" + if fm.isExecutableFile(atPath: "\(bin)/node") { return bin } + } + return nil + } + + /// Build the full environment dictionary for spawned subprocesses, + /// inheriting the GUI environment but with PATH replaced by ``resolvedShellPath()``. + func resolvedEnvironment() async -> [String: String] { + var env = ProcessInfo.processInfo.environment + env["PATH"] = await resolvedShellPath() + return env + } + + // MARK: - Binary Discovery + + /// Well-known paths searched in order before falling back to the shell. + static var candidatePaths: [String] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return [ + "/usr/local/bin/claude", + "/opt/homebrew/bin/claude", + "\(home)/.local/bin/claude", + "\(home)/.npm-global/bin/claude", + ] + } + + /// Locate the `claude` binary on this machine. + func findClaudeBinary() async -> String? { + let fm = FileManager.default + + for path in Self.candidatePaths { + // Resolve symlinks before checking + let resolved = (path as NSString).resolvingSymlinksInPath + if fm.fileExists(atPath: resolved) && fm.isExecutableFile(atPath: path) { + logger.info("Found claude binary at \(path, privacy: .public) -> \(resolved, privacy: .public)") + return path + } + } + + // Shell fallback + logger.info("Trying shell fallback to locate claude binary") + do { + let result = try await runShellCommand("/bin/zsh", arguments: ["-ilc", "whence -p claude"]) + let path = result.trimmingCharacters(in: .whitespacesAndNewlines) + if !path.isEmpty, fm.isExecutableFile(atPath: path) { + logger.info("Found claude binary via shell at \(path, privacy: .public)") + return path + } + } catch { + logger.warning("Shell fallback failed: \(error, privacy: .public)") + } + + logger.error("claude binary not found") + return nil + } + + // MARK: - Local Command + + /// Run a local slash command (e.g. "/cost", "/usage") and return stdout. + func runLocalCommand(_ command: String) async throws -> String { + guard let binary = await findClaudeBinary() else { + throw ClaudeError.binaryNotFound + } + + let output = try await runShellCommand(binary, arguments: ["-p", command, "--output-format", "text"]) + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Run `/context` for a session and parse the used percentage. + /// Returns nil if the session has no context info or parsing fails. + func fetchContextPercentage(sessionId: String, cwd: String) async -> Double? { + guard let binary = await findClaudeBinary() else { return nil } + do { + let output = try await runShellCommand( + binary, + arguments: ["-p", "/context", "--output-format", "text", "--resume", sessionId], + cwd: cwd + ) + // Parse "Tokens: 24.2k / 200k (12%)" pattern + guard let match = output.range(of: #"\((\d+(?:\.\d+)?)%\)"#, options: .regularExpression) else { + return nil + } + let captured = output[match].dropFirst(1).dropLast(2) // remove "(" and "%)" + return Double(captured) + } catch { + logger.warning("Failed to fetch context: \(error.localizedDescription)") + return nil + } + } + + // MARK: - Version Check + + /// Run `claude --version` and return the version string. + func checkVersion() async throws -> String { + guard let binary = await findClaudeBinary() else { + throw ClaudeError.binaryNotFound + } + + let output = try await runShellCommand(binary, arguments: ["--version"]) + let version = output.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"\s*\(Claude Code\)"#, with: "", options: .regularExpression) + + guard !version.isEmpty else { + throw ClaudeError.versionCheckFailed("Empty version output") + } + + logger.info("Claude CLI version: \(version, privacy: .public)") + return version + } + + // MARK: - Shell Command Runner + + /// Run a simple command and return its stdout as a String. + /// Uses async termination handling to avoid blocking the actor's cooperative thread. + /// + /// `injectPath` controls whether the spawned process receives the resolved + /// shell PATH. Set to `false` when this method is itself used to *resolve* + /// the shell PATH, to break the chicken-and-egg loop. + func runShellCommand( + _ command: String, + arguments: [String] = [], + cwd: String? = nil, + injectPath: Bool = true + ) async throws -> String { + let proc = Process() + let pipe = Pipe() + + proc.executableURL = URL(fileURLWithPath: command) + proc.arguments = arguments + proc.standardOutput = pipe + proc.standardError = FileHandle.nullDevice + proc.environment = injectPath + ? await resolvedEnvironment() + : ProcessInfo.processInfo.environment + if let cwd { proc.currentDirectoryURL = URL(fileURLWithPath: cwd) } + + try proc.run() + + // Wait for process exit asynchronously instead of blocking + await withCheckedContinuation { (continuation: CheckedContinuation) in + proc.terminationHandler = { _ in + continuation.resume() + } + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/RxCode/Services/ClaudeService+Process.swift b/RxCode/Services/ClaudeService+Process.swift new file mode 100644 index 0000000..d393d8c --- /dev/null +++ b/RxCode/Services/ClaudeService+Process.swift @@ -0,0 +1,707 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - Process Spawn & Streaming + +extension ClaudeCodeServer { + + // MARK: - Send (spawn + stream) + + /// Spawn the CLI and return a stream of parsed events. + /// + /// Architecture: a single `Task.detached` reads stdout line-by-line, + /// decodes NDJSON, and yields `StreamEvent`s. No intermediate streams, + /// no shared-actor scheduling issues. + /// + /// Multiple concurrent streams are managed independently via `streamId`. + func send( + streamId: UUID, + prompt: String, + cwd: String, + sessionId: String? = nil, + model: String? = nil, + effort: String? = nil, + hookSettingsPath: String? = nil, + mcpConfigPath: String? = nil, + extraSystemPrompt: String? = nil, + permissionMode: PermissionMode = .default + ) -> AsyncStream { + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + + let log = self.logger + let currentStreamId = streamId + + readStderr(stderr, streamId: currentStreamId) + + return AsyncStream { continuation in + let task = Task.detached { [weak self] in + guard let self else { + continuation.finish() + return + } + + // Spawn process (hops to ClaudeService actor for state) + do { + try await self.spawnProcess( + streamId: streamId, + prompt: prompt, + cwd: cwd, + sessionId: sessionId, + model: model, + effort: effort, + hookSettingsPath: hookSettingsPath, + mcpConfigPath: mcpConfigPath, + extraSystemPrompt: extraSystemPrompt, + permissionMode: permissionMode, + stdinPipe: stdin, + stdoutPipe: stdout, + stderrPipe: stderr, + onProcessExit: { + // Wait 2 seconds after process exit to flush remaining buffers + // before finishing the stream. continuation.finish() is thread-safe and + // idempotent, so duplicate calls on normal exit are safe. + DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { + continuation.finish() + } + } + ) + } catch { + log.error("[Stream] spawn failed: \(error.localizedDescription)") + continuation.finish() + return + } + + // Read stdout line-by-line — ends naturally at EOF + var parsedCount = 0 + var failedCount = 0 + let decoder = JSONDecoder() + log.info("[Stream] starting stdout read loop") + + var rawLineCount = 0 + var capturedSessionId: String? + do { + for try await line in stdout.fileHandleForReading.bytes.lines { + guard !line.isEmpty else { continue } + guard let data = line.data(using: .utf8) else { continue } + + rawLineCount += 1 + // Diagnostic logging of raw NDJSON — full content for first 30 lines, then type field only + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let type = (json["type"] as? String) ?? "?" + if rawLineCount <= 30 { + log.info("[Stream:RAW] #\(rawLineCount) type=\(type) line=\(line.prefix(600))") + } else if type == "stream_event" || rawLineCount % 50 == 0 { + log.info("[Stream:RAW] #\(rawLineCount) type=\(type)") + } + if capturedSessionId == nil, + let sid = (json["session_id"] as? String) ?? (json["sessionId"] as? String) { + capturedSessionId = sid + Task { await self.recordSessionId(streamId: streamId, sessionId: sid) } + } + } else if rawLineCount <= 30 { + log.info("[Stream:RAW] #\(rawLineCount) non-JSON line=\(line.prefix(600))") + } + + do { + let event = try decoder.decode(StreamEvent.self, from: data) + parsedCount += 1 + continuation.yield(event) + } catch { + failedCount += 1 + // Yield raw string so partial events still reach the UI + continuation.yield(.unknown(line)) + if failedCount <= 5 { + log.warning("[Stream] parse failed #\(failedCount): \(line.prefix(200))") + } + } + } + } catch { + log.warning("[Stream] stdout read error: \(error.localizedDescription)") + } + + log.info("[Stream] stdout ended (parsed=\(parsedCount), failed=\(failedCount))") + continuation.finish() + } + + continuation.onTermination = { reason in + log.info("[Stream] terminated (reason=\(String(describing: reason)))") + task.cancel() + // Close the pipe after the stream ends to unblock the bytes.lines read. + // onTermination is called after finish(), so there is no data loss. + stdout.fileHandleForReading.closeFile() + } + } + } + + // MARK: - Descendant tracker + + /// Start a background poller that periodically snapshots every descendant of `root` + /// and merges them into `trackedDescendants[streamId]`. The accumulated set is the + /// safety net for descendants that briefly exist as findable children of `root` + /// before detaching themselves (via `setsid`/`setpgid`) and being reparented away. + /// + /// Cancelled in `removeProcess` when the stream ends. + func startDescendantTracker(streamId: UUID, root: pid_t) { + // Capture sid once at startup — getsid() on a live root returns the session id, + // which equals `root` itself since we spawned with POSIX_SPAWN_SETSID. + let sid = getsid(root) + let task = Task.detached { [weak self] in + while !Task.isCancelled { + let pids = Self.descendantPids(of: root, sid: sid) + if !pids.isEmpty { + await self?.mergeTrackedDescendants(streamId: streamId, pids: pids) + } + // 500ms is a balance: short enough to catch transient ppid links before + // an intermediate parent dies and reparenting hides the child, while not + // burning measurable CPU on the `ps` invocation (~5ms per snapshot). + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + descendantTrackers[streamId] = task + } + + func mergeTrackedDescendants(streamId: UUID, pids: [pid_t]) { + trackedDescendants[streamId, default: []].formUnion(pids) + } + + /// Union of the live snapshot and every descendant ever seen for this stream. + /// `kill(pid, 0)` filters out already-reaped pids so signals only target live ones. + func allKnownDescendants(streamId: UUID, root: pid_t, sid: pid_t) -> [pid_t] { + var union = trackedDescendants[streamId] ?? [] + union.formUnion(Self.descendantPids(of: root, sid: sid)) + return union.filter { kill($0, 0) == 0 } + } + + // MARK: - Cancel / Finalize + + /// User-initiated stop. Send SIGINT to the entire process group so subagent + /// children die alongside the parent. Escalate to SIGKILL after 5 seconds. + func cancel(streamId: UUID) { + guard let pgid = streamPGIDs[streamId] else { return } + // Capture sid while the root is still alive — getsid(pid) returns -1 once + // the process is fully reaped, but the value is needed for the SIGKILL re-snapshot. + let sid = getsid(pgid) + let escapees = allKnownDescendants(streamId: streamId, root: pgid, sid: sid) + + logger.info("Sending SIGINT to claude pgid \(pgid) escapees=\(escapees) (stream=\(streamId))") + killpg(pgid, SIGINT) + for pid in escapees { kill(pid, SIGINT) } + + let log = logger + Task.detached { [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + // Re-snapshot before SIGKILL — picks up anything that emerged in the 5s window. + let finalEscapees = await self?.allKnownDescendants(streamId: streamId, root: pgid, sid: sid) ?? [] + // killpg/kill on a fully-dead target returns ESRCH — harmless. Send unconditionally + // to cover any subagent that ignored SIGINT or escaped the process group. + killpg(pgid, SIGKILL) + for pid in finalEscapees { kill(pid, SIGKILL) } + log.debug("Cancel SIGKILL pgid=\(pgid) escapees=\(finalEscapees)") + } + } + + /// Thread-finished sweep. Called after the `result` event (and from the exit + /// handler) to guarantee no subagent process outlives the parent CLI. + /// + /// MCP servers and Node children spawned `detached: true` may escape the + /// parent's process group (via `setsid`/`setpgid`), so `killpg` alone is not + /// enough. Snapshot the descendant tree before signaling and SIGKILL each + /// escapee individually as the safety net — running in a detached Task so + /// actor contention can't delay the escalation. + func finalize(streamId: UUID) { + // Claim the entry atomically so a second concurrent caller (e.g. AppState's + // result-driven finalize racing with the waitpid-driven handleProcessExit) + // becomes a no-op instead of re-signaling an already-reaped pgid. + guard let pgid = streamPGIDs.removeValue(forKey: streamId) else { return } + // Capture sid while the root is still alive — see cancel() for rationale. + let sid = getsid(pgid) + let escapees = allKnownDescendants(streamId: streamId, root: pgid, sid: sid) + + logger.info("Finalizing stream — pgid=\(pgid) sid=\(sid) escapees=\(escapees) stream=\(streamId)") + killpg(pgid, SIGTERM) + for pid in escapees { kill(pid, SIGTERM) } + + let log = logger + Task.detached { [weak self] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + // Re-snapshot before SIGKILL. By this time the root may have already + // exited; the accumulated `trackedDescendants` set is what catches + // session-escaped, reparented processes that no live ps query can find. + let finalEscapees = await self?.allKnownDescendants(streamId: streamId, root: pgid, sid: sid) ?? [] + killpg(pgid, SIGKILL) + for pid in finalEscapees { kill(pid, SIGKILL) } + log.debug("Finalize SIGKILL pgid=\(pgid) escapees=\(finalEscapees)") + } + } + + /// Find every descendant pid of `root` (not including `root`). Combines two + /// strategies for maximum coverage: + /// + /// 1. **Parent walk** — BFS via `ps -Ao pid,ppid`. Catches everything reachable + /// through ppid links from `root`. Works for live, non-reparented trees but + /// breaks once an intermediate parent dies and its children are reparented + /// to launchd (ppid=1). + /// 2. **Session match** — for each running pid, compare `getsid(pid)` to the + /// passed-in `sid`. Catches descendants that broke out of the pgid (called + /// `setpgid`) and whose ppid chain was severed by reparenting, as long as + /// they did not call `setsid` themselves. Relies on the root having been + /// spawned with `POSIX_SPAWN_SETSID` so that `sid == root`. + /// + /// Pass `sid: 0` to skip the session match (e.g., if `getsid(root)` already + /// returned an error). Callers should capture `sid` while the root is alive, + /// since `getsid()` on a reaped pid returns -1. + static func descendantPids(of root: pid_t, sid: pid_t) -> [pid_t] { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/bin/ps") + proc.arguments = ["-Ao", "pid,ppid"] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = FileHandle.nullDevice + do { + try proc.run() + proc.waitUntilExit() + } catch { + return [] + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { return [] } + + var childrenByParent: [pid_t: [pid_t]] = [:] + var allPids: [pid_t] = [] + for line in text.split(separator: "\n").dropFirst() { + let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) + guard parts.count >= 2, + let pid = pid_t(parts[0]), + let ppid = pid_t(parts[1]) else { continue } + childrenByParent[ppid, default: []].append(pid) + allPids.append(pid) + } + + var result = Set() + + // (1) BFS via ppid links — works while parent chain is intact. + var queue: [pid_t] = [root] + while !queue.isEmpty { + let next = queue.removeFirst() + guard let children = childrenByParent[next] else { continue } + for child in children where child != root { + if result.insert(child).inserted { + queue.append(child) + } + } + } + + // (2) Session-id match — survives reparenting; misses processes that + // called setsid themselves (rare for CLI subagents). + if sid > 0 { + for pid in allPids where pid != root { + if getsid(pid) == sid { + result.insert(pid) + } + } + } + + return Array(result) + } + + // MARK: - Argument Building + + /// Extra system-prompt text appended via `--append-system-prompt`. Tells the + /// agent about the IDE-provided `rxcode-ide` MCP server, which lets it talk + /// to agents in other RxCode projects/threads, introspect editor state, and + /// recall/store durable cross-session memories. + static let ideToolsSystemPrompt = """ + # RxCode IDE tools + + You are running inside RxCode, a desktop IDE that hosts multiple projects, \ + each with its own chat threads and agents. RxCode injects a local MCP \ + server named `rxcode-ide`; its tools are exposed to you with the \ + `mcp__rxcode-ide__` prefix. Use them to coordinate with other agents \ + (multi-agent talk) and to read editor state: + + - `mcp__rxcode-ide__ide__get_projects` — list every project registered in \ + RxCode, so you can discover sibling projects to read or message. + - `mcp__rxcode-ide__ide__get_threads` — list or natural-language search \ + chat threads across projects (returns AI summaries, and ranked snippets \ + when a query is given). + - `mcp__rxcode-ide__ide__get_thread_messages` — fetch the message history \ + of a specific thread by id. + - `mcp__rxcode-ide__ide__send_to_thread` — talk to another project's agent: \ + send a prompt to an existing thread (`thread_id`) or start a new thread in \ + a project (`project_id`). This triggers a real agent run that may consume \ + tokens; it returns the other agent's reply. + - `mcp__rxcode-ide__ide__get_running_jobs` / `ide__get_job_output` — \ + inspect run-profile jobs (dev servers, scripts) executing in the IDE. + - `mcp__rxcode-ide__ide__get_usage` — current rate-limit / token usage. + + Reach for these when a task spans projects — e.g. checking what another \ + project already did, or delegating a subtask to its agent — rather than \ + guessing. Prefer reading threads first; only use `ide__send_to_thread` when \ + you actually need another agent to act, since it costs tokens. + + RxCode also persists durable memories — stable user preferences, project \ + facts, and decisions — across sessions. Use these tools to recall and \ + store them: + + - `mcp__rxcode-ide__ide__memory_search` — before starting a task, search \ + for saved preferences, facts, or decisions relevant to it instead of \ + asking the user to repeat themselves. + - `mcp__rxcode-ide__ide__memory_add` — when the user states a stable \ + preference, project fact, or decision ("remember…", "from now on…", \ + "always…"), save it. Set `kind` (`preference`/`fact`/`decision`) and \ + `scope` (`global` for cross-project, `project` for repo-specific). + - `mcp__rxcode-ide__ide__memory_update` — when saved information changes, \ + update the existing entry by `id` rather than adding a duplicate. + - `mcp__rxcode-ide__ide__memory_delete` — remove a memory by `id` when it \ + is no longer valid. + + Only store stable, reusable information in memory — not transient task \ + details. + """ + + /// Build arguments array for the CLI invocation. + /// + /// The user prompt is NOT a CLI argument — it is written to stdin as a JSON + /// user message (see `spawnProcess`) because we run the CLI with + /// `--input-format stream-json`. + func buildArguments( + sessionId: String?, + model: String?, + effort: String?, + hookSettingsPath: String?, + mcpConfigPath: String?, + extraSystemPrompt: String?, + permissionMode: PermissionMode + ) -> [String] { + var args: [String] = [ + "-p", + "--input-format", "stream-json", + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + ] + + if permissionMode != .default { + args += ["--permission-mode", permissionMode.rawValue] + } + + if !permissionMode.skipsHookPipeline { + // Pre-approve safe tools that don't need to go through hooks via --allowedTools. + // This eliminates HTTP round-trips from internal agent mechanics like Read/Grep/Task, + // since no approval UI is shown for these. + let safeTools = [ + "Read", "Glob", "Grep", "LS", + "TodoRead", "TodoWrite", + "Agent", "Task", "TaskOutput", + "Notebook", "NotebookEdit", + "WebSearch", "WebFetch", + ] + args += ["--allowedTools", safeTools.joined(separator: ",")] + } + + if let hookSettingsPath { + args += ["--settings", hookSettingsPath] + } + + if let mcpConfigPath { + args += ["--strict-mcp-config", "--mcp-config", mcpConfigPath] + } + + // Assemble the system-prompt additions for this turn into a single + // `--append-system-prompt` value (the CLI honours one occurrence): + // - IDE tools blurb, when the `rxcode-ide` MCP server is wired in. + // - Caller-supplied context, e.g. the current branch briefing. + var systemPromptSections: [String] = [] + if mcpConfigPath != nil { + systemPromptSections.append(Self.ideToolsSystemPrompt) + } + if let extraSystemPrompt, !extraSystemPrompt.isEmpty { + systemPromptSections.append(extraSystemPrompt) + } + if !systemPromptSections.isEmpty { + args += ["--append-system-prompt", systemPromptSections.joined(separator: "\n\n")] + } + + if let sessionId { + args += ["--resume", sessionId] + } + + if let model { + args += ["--model", model] + } + + if let effort { + args += ["--effort", effort] + } + + // With `--input-format stream-json`, the prompt is sent via stdin as a JSON + // user message (see spawnProcess) rather than as a CLI argument. + return args + } + + // MARK: - Spawn + + /// Launch the streaming `claude` CLI in its own process group via `posix_spawn`. + /// + /// Foundation's `Process` does not expose `posix_spawnattr_t`, so the streaming + /// invocation goes through the raw POSIX API. The new group means the leader's + /// pid is also its pgid — `killpg(pid, ...)` reaches every subagent the CLI + /// spawns via the Task tool. + func spawnProcess( + streamId: UUID, + prompt: String, + cwd: String, + sessionId: String?, + model: String?, + effort: String? = nil, + hookSettingsPath: String?, + mcpConfigPath: String?, + extraSystemPrompt: String? = nil, + permissionMode: PermissionMode = .default, + stdinPipe: Pipe, + stdoutPipe: Pipe, + stderrPipe: Pipe, + onProcessExit: (@Sendable () -> Void)? = nil + ) async throws { + guard let binary = await findClaudeBinary() else { + throw ClaudeError.binaryNotFound + } + + let arguments = buildArguments( + sessionId: sessionId, + model: model, + effort: effort, + hookSettingsPath: hookSettingsPath, + mcpConfigPath: mcpConfigPath, + extraSystemPrompt: extraSystemPrompt, + permissionMode: permissionMode + ) + let environment = await resolvedEnvironment() + + let stdinReadFD = stdinPipe.fileHandleForReading.fileDescriptor + let stdoutWriteFD = stdoutPipe.fileHandleForWriting.fileDescriptor + let stderrWriteFD = stderrPipe.fileHandleForWriting.fileDescriptor + + let pid: pid_t + do { + pid = try Self.spawnInNewProcessGroup( + executable: binary, + arguments: arguments, + environment: environment, + cwd: cwd, + stdinReadFD: stdinReadFD, + stdoutWriteFD: stdoutWriteFD, + stderrWriteFD: stderrWriteFD + ) + } catch { + logger.error("Failed to spawn claude: \(error, privacy: .public)") + throw ClaudeError.spawnFailed(error.localizedDescription) + } + + // Parent must release the child-owned pipe ends so EOF propagates correctly. + try? stdinPipe.fileHandleForReading.close() + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + + // Keep stdin open for stream-json input protocol. closed in `closeStdin(streamId:)` + // after the `result` event so the CLI can flush remaining output and exit. + let stdinHandle = stdinPipe.fileHandleForWriting + self.streamPGIDs[streamId] = pid + self.stdinHandles[streamId] = stdinHandle + self.startDescendantTracker(streamId: streamId, root: pid) + + // Send the initial user prompt as an NDJSON user message. + let userMessage: [String: Any] = [ + "type": "user", + "message": [ + "role": "user", + "content": [ + ["type": "text", "text": prompt] + ] + ] + ] + try Self.writeJSONLine(userMessage, to: stdinHandle) + + logger.info( + "Spawned claude process pid=\(pid) pgid=\(pid) cwd=\(cwd, privacy: .public) stream=\(streamId)" + ) + + // Wait for the parent CLI to exit on a background thread, then sweep + // the process group to reap any subagent children that outlived it. + let log = logger + let cwdCopy = cwd + Task.detached { [weak self] in + var status: Int32 = 0 + var rc: pid_t = 0 + repeat { + rc = waitpid(pid, &status, 0) + } while rc < 0 && errno == EINTR + + log.info( + "claude process exited — pid=\(pid) raw_status=\(status) stream=\(streamId)" + ) + + await self?.handleProcessExit(streamId: streamId, cwd: cwdCopy) + onProcessExit?() + } + } + + /// Run after the parent CLI's `waitpid` returns. Sweep any surviving subagents + /// in the same process group, then drop bookkeeping for the stream. + func handleProcessExit(streamId: UUID, cwd: String) async { + finalize(streamId: streamId) + removeProcess(streamId: streamId) + if let sid = consumeSessionId(streamId: streamId) { + await cliStore.exposeToPicker(sid: sid, cwd: cwd) + } + } + + /// Spawn `executable` with `arguments` in its own process group. + /// Returns the child pid (== pgid since `POSIX_SPAWN_SETPGROUP` with group 0 + /// makes the child its own group leader). + static func spawnInNewProcessGroup( + executable: String, + arguments: [String], + environment: [String: String], + cwd: String, + stdinReadFD: Int32, + stdoutWriteFD: Int32, + stderrWriteFD: Int32 + ) throws -> pid_t { + var fileActions: posix_spawn_file_actions_t? + guard posix_spawn_file_actions_init(&fileActions) == 0 else { + throw ClaudeError.spawnFailed("posix_spawn_file_actions_init failed") + } + defer { posix_spawn_file_actions_destroy(&fileActions) } + + _ = posix_spawn_file_actions_adddup2(&fileActions, stdinReadFD, 0) + _ = posix_spawn_file_actions_adddup2(&fileActions, stdoutWriteFD, 1) + _ = posix_spawn_file_actions_adddup2(&fileActions, stderrWriteFD, 2) + + let chdirRC = cwd.withCString { cwdPtr in + posix_spawn_file_actions_addchdir_np(&fileActions, cwdPtr) + } + if chdirRC != 0 { + throw ClaudeError.spawnFailed("posix_spawn_file_actions_addchdir_np failed: \(chdirRC)") + } + + var attr: posix_spawnattr_t? + guard posix_spawnattr_init(&attr) == 0 else { + throw ClaudeError.spawnFailed("posix_spawnattr_init failed") + } + defer { posix_spawnattr_destroy(&attr) } + + // SETSID makes the child a new session leader (sid == pid == pgid). Session id + // is preserved across reparenting, so we can locate descendants via getsid() + // even after an intermediate parent has died and orphans were reparented to + // launchd. Pure SETPGROUP would not survive reparenting on its own. + _ = posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETSID)) + + var argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } + argv.append(nil) + defer { for p in argv { if let p = p { free(p) } } } + + let envEntries = environment.map { "\($0.key)=\($0.value)" } + var envp: [UnsafeMutablePointer?] = envEntries.map { strdup($0) } + envp.append(nil) + defer { for p in envp { if let p = p { free(p) } } } + + var pid: pid_t = 0 + let rc = executable.withCString { execPath in + posix_spawn(&pid, execPath, &fileActions, &attr, argv, envp) + } + if rc != 0 { + let msg = String(cString: strerror(rc)) + throw ClaudeError.spawnFailed("posix_spawn failed: \(msg) (\(rc))") + } + return pid + } + + // MARK: - Stdin Writer + + /// Serialize a dictionary to JSON and write to stdin as one NDJSON line. + /// Non-isolated to allow use from `spawnProcess` after `try proc.run()`. + static func writeJSONLine(_ object: [String: Any], to handle: FileHandle) throws { + let data = try JSONSerialization.data(withJSONObject: object, options: []) + handle.write(data) + handle.write(Data([0x0A])) // newline + } + + /// Close stdin for an active stream. Call this after receiving the `result` event + /// so the CLI process exits cleanly once it has flushed all remaining output. + func closeStdin(streamId: UUID) { + guard let handle = stdinHandles.removeValue(forKey: streamId) else { return } + do { + try handle.close() + logger.info("Closed stdin for stream=\(streamId)") + } catch { + logger.warning("closeStdin error for stream=\(streamId): \(error.localizedDescription)") + } + } + + /// Remove a stream's bookkeeping from within actor isolation, called from + /// the waitpid-driven exit handler. + func removeProcess(streamId: UUID) { + streamPGIDs.removeValue(forKey: streamId) + descendantTrackers.removeValue(forKey: streamId)?.cancel() + // Retain `trackedDescendants[streamId]` long enough for the SIGKILL re-snapshot + // in cancel/finalize (those run in detached tasks after this method). Clear it + // after a short delay so the actor doesn't accumulate stale entries. + let clearKey = streamId + Task { [weak self] in + try? await Task.sleep(nanoseconds: 6_000_000_000) + await self?.clearTrackedDescendants(streamId: clearKey) + } + // If stdin is still open (e.g. abnormal exit before `result`), release the handle. + if let handle = stdinHandles.removeValue(forKey: streamId) { + try? handle.close() + } + } + + func clearTrackedDescendants(streamId: UUID) { + trackedDescendants.removeValue(forKey: streamId) + } + + func recordSessionId(streamId: UUID, sessionId: String) { + streamSessionIds[streamId] = sessionId + } + + func consumeSessionId(streamId: UUID) -> String? { + streamSessionIds.removeValue(forKey: streamId) + } + + /// Read stderr asynchronously, log each line, and buffer for error reporting. + nonisolated func readStderr(_ pipe: Pipe, streamId: UUID) { + let log = logger + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + pipe.fileHandleForReading.readabilityHandler = nil + return + } + if let text = String(data: data, encoding: .utf8) { + for line in text.split(separator: "\n") { + log.debug("[stderr] \(line, privacy: .public)") + } + Task { await self?.appendStderr(text, for: streamId) } + } + } + } + + /// Append text to the stderr buffer + func appendStderr(_ text: String, for streamId: UUID) { + stderrBuffers[streamId, default: ""] += text + } + + /// Consume and return the stderr buffer for a given stream + func consumeStderr(for streamId: UUID) -> String? { + guard let buffer = stderrBuffers.removeValue(forKey: streamId), + !buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return buffer.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/RxCode/Services/ClaudeService+Summaries.swift b/RxCode/Services/ClaudeService+Summaries.swift new file mode 100644 index 0000000..38f165c --- /dev/null +++ b/RxCode/Services/ClaudeService+Summaries.swift @@ -0,0 +1,208 @@ +import Foundation +import RxCodeCore +import os + +// MARK: - Title & Summary Generation + +extension ClaudeCodeServer { + + /// Generate a short 3–6 word title for a chat from the first user message. + /// Uses a one-shot `claude -p` invocation with Haiku — runs outside of the streaming + /// pipeline and does NOT hit the PermissionServer hook (no `--settings` passed). + /// Returns nil on any failure; callers should keep the placeholder title in that case. + func generateSessionTitle(firstUserMessage: String, model: String = "claude-haiku-4-5-20251001") async -> String? { + guard let binary = await findClaudeBinary() else { return nil } + let trimmedUser = String(firstUserMessage.prefix(500)) + let prompt = """ + Summarize the following user message as a 3-6 word chat title. \ + Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. + + \(trimmedUser) + """ + // Title generation is pure text — no tools needed. Strip MCP servers so a + // user's broken tool schema (e.g. an MCP server returning invalid JSON schema) + // can't blow up the title call with "API Error: 400 tools.NN.custom.input_schema". + let emptyMCPConfigPath = writeEmptyMCPConfig() + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] + if let emptyMCPConfigPath { + args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) + } + do { + let output = try await runShellCommand(binary, arguments: args) + let cleaned = output + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + // `claude -p` prints API/CLI failures to stdout (e.g. "API Error: 400 ..."). + // Don't promote those to the sidebar title — keep the placeholder instead. + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] + if errorPrefixes.contains(where: { lower.hasPrefix($0) }) { + logger.warning("Title generation produced an error string; ignoring: \(cleaned.prefix(120))") + return nil + } + return String(cleaned.prefix(80)) + } catch { + logger.warning("Title generation failed: \(error.localizedDescription)") + return nil + } + } + + func generateResponseNotificationSummary(responseText: String, model: String = "claude-haiku-4-5-20251001") async -> String? { + guard let binary = await findClaudeBinary() else { return nil } + let trimmedResponse = String(responseText.prefix(4000)) + let prompt = """ + Summarize the following assistant response for a macOS notification. \ + Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. \ + No markdown. + + \(trimmedResponse) + """ + let emptyMCPConfigPath = writeEmptyMCPConfig() + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] + if let emptyMCPConfigPath { + args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) + } + do { + let output = try await runShellCommand(binary, arguments: args) + let cleaned = output + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] + if errorPrefixes.contains(where: { lower.hasPrefix($0) }) { + logger.warning("Notification summary produced an error string; ignoring: \(cleaned.prefix(120))") + return nil + } + return String(cleaned.prefix(180)) + } catch { + logger.warning("Notification summary generation failed: \(error.localizedDescription)") + return nil + } + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = """ + Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. + Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. + Reply with only the updated summary. + + Previous summary: + \((previous?.isEmpty == false) ? previous! : "None") + + Latest user request: + \(String(userMessage.prefix(2000))) + + Final assistant response: + \(String(finalResponse.prefix(4000))) + """ + return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) + } + + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + let prompt = OpenAISummarizationService.memoryExtractionPrompt( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + return await generatePlainSummary(prompt: prompt, model: model, limit: 3000) + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) + return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) + } + + func generateCommitMessage( + diff: String, + fileSummary: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + // Caller (AppState) has already applied a provider-aware budget; this + // is just an upper bound to guard against accidental misuse. + let trimmedDiff = String(diff.prefix(20_000)) + let prompt = """ + Write a Git commit message for the staged changes below in the Conventional Commits format. + + Format rules (MUST follow exactly): + - First line: `(): ` — subject must be under 72 characters, lowercase imperative mood, no trailing period. + - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - After the subject, an optional blank line followed by 1-3 short bullet points explaining the WHY (each starting with "- "). + - Do NOT use markdown headings (no `#`, `##`). + - Do NOT wrap the message in quotes or code fences. + - Do NOT prefix with anything else; the very first characters must be the type. + + Example output: + feat(git): add commit message generator + + - reuse summarization providers for on-device generation + - support staged diff context + + Staged files: + \(fileSummary) + + Staged diff: + \(trimmedDiff) + """ + return await generatePlainSummary(prompt: prompt, model: model, limit: 1000) + } + + func generatePlainSummary(prompt: String, model: String, limit: Int) async -> String? { + guard let binary = await findClaudeBinary() else { return nil } + let emptyMCPConfigPath = writeEmptyMCPConfig() + var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] + if let emptyMCPConfigPath { + args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) + } + do { + let output = try await runShellCommand(binary, arguments: args) + return cleanGeneratedSummary(output, limit: limit) + } catch { + logger.warning("Summary generation failed: \(error.localizedDescription)") + return nil + } + } + + func cleanGeneratedSummary(_ raw: String, limit: Int) -> String? { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } + + /// Write a one-off MCP config file (with no servers) used by the title-generation + /// call so it doesn't inherit user-level MCP servers. Returns nil on I/O failure; + /// caller falls back to the default config. + func writeEmptyMCPConfig() -> String? { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("RxCode", isDirectory: true) + let path = dir.appendingPathComponent("empty-mcp.json") + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data("{\"mcpServers\":{}}".utf8).write(to: path, options: .atomic) + return path.path + } catch { + logger.warning("Failed to write empty MCP config: \(error.localizedDescription)") + return nil + } + } +} diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index c6c2aca..c9daca4 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -8,6 +8,11 @@ import os /// /// Spawns the `claude` binary with stream-json I/O, reads stdout as an /// ``AsyncStream``, and writes user messages to stdin in NDJSON format. +/// +/// The implementation is split across extensions in sibling files: +/// - `ClaudeService+Discovery.swift` — PATH/binary discovery, version, shell runner +/// - `ClaudeService+Summaries.swift` — title/commit/thread summary generation +/// - `ClaudeService+Process.swift` — spawn, streaming, descendant tracking, cancel actor ClaudeCodeServer { // MARK: - State @@ -19,7 +24,7 @@ actor ClaudeCodeServer { /// entire subagent subtree with a single `killpg` instead of chasing descendants /// individually, and also enables session-id filtering to find descendants whose /// parent chain was severed by reparenting to launchd. - private var streamPGIDs: [UUID: pid_t] = [:] + var streamPGIDs: [UUID: pid_t] = [:] /// Accumulated set of every descendant pid ever observed for a stream. A background /// poller samples the live process table while the stream is running and unions the /// results here. This is the only way to catch descendants that call `setsid()` @@ -27,25 +32,34 @@ actor ClaudeCodeServer { /// get reparented to launchd when an intermediate parent dies — by the time /// `finalize` runs, those processes are invisible to both the ppid walk and the /// session-id filter, but they were briefly findable while their parent was alive. - private var trackedDescendants: [UUID: Set] = [:] + var trackedDescendants: [UUID: Set] = [:] /// Polling tasks that populate `trackedDescendants`. Cancelled in `removeProcess`. - private var descendantTrackers: [UUID: Task] = [:] + var descendantTrackers: [UUID: Task] = [:] /// Writable stdin handles per stream — used for sending follow-up messages (e.g., AskUserQuestion responses). /// Entry is removed when stdin is closed (after `result` event or on cancel). - private var stdinHandles: [UUID: FileHandle] = [:] - private var inactivityTimer: Task? + var stdinHandles: [UUID: FileHandle] = [:] + var inactivityTimer: Task? /// Per-stream stderr accumulator — used to deliver error messages when process exits without a response - private var stderrBuffers: [UUID: String] = [:] + var stderrBuffers: [UUID: String] = [:] - private var streamSessionIds: [UUID: String] = [:] + var streamSessionIds: [UUID: String] = [:] - private let cliStore: CLISessionStore - private let logger = Logger( + let cliStore: CLISessionStore + let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.claudework", category: "ClaudeCodeServer" ) + /// Cached PATH used for spawned subprocesses. Built once on first use. + /// + /// macOS GUI apps inherit a minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin`) + /// that excludes Homebrew, nvm, and npm-global locations. Without overriding + /// PATH for spawned processes, the `claude` CLI fails with + /// `env: node: No such file or directory` when its `node` shebang resolver + /// cannot locate Node. + var cachedShellPath: String? + init(cliStore: CLISessionStore) { self.cliStore = cliStore } @@ -75,1134 +89,6 @@ actor ClaudeCodeServer { } } - // MARK: - Shell PATH Resolution - - /// Cached PATH used for spawned subprocesses. Built once on first use. - /// - /// macOS GUI apps inherit a minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin`) - /// that excludes Homebrew, nvm, and npm-global locations. Without overriding - /// PATH for spawned processes, the `claude` CLI fails with - /// `env: node: No such file or directory` when its `node` shebang resolver - /// cannot locate Node. - private var cachedShellPath: String? - - /// Compose a PATH that lets the spawned `claude` CLI find `node` and - /// related tools regardless of where the user installed them. - /// - /// Combines, in priority order: - /// 1. The user's interactive login shell PATH (captures nvm/asdf/.zshrc init) - /// 2. Well-known tool directories (Homebrew, npm-global, nvm latest) - /// 3. The GUI process's existing PATH as a final fallback - private func resolvedShellPath() async -> String { - if let cached = cachedShellPath { return cached } - - var paths: [String] = [] - var seen = Set() - func add(_ entry: String) { - let trimmed = entry.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return } - paths.append(trimmed) - } - - if let shellPath = await readUserShellPath() { - for component in shellPath.split(separator: ":") { add(String(component)) } - } - - let home = FileManager.default.homeDirectoryForCurrentUser.path - for dir in [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "\(home)/.local/bin", - "\(home)/.npm-global/bin", - ] { add(dir) } - - if let nvmBin = latestNvmBinDirectory(home: home) { add(nvmBin) } - - if let existing = ProcessInfo.processInfo.environment["PATH"] { - for component in existing.split(separator: ":") { add(String(component)) } - } - - // Double-check after awaits: another reentrant caller may have populated it. - if let cached = cachedShellPath { return cached } - - let combined = paths.joined(separator: ":") - cachedShellPath = combined - logger.info("Resolved shell PATH for subprocess (entries=\(paths.count))") - return combined - } - - /// Spawn the user's login shell once to read its `$PATH`. - /// Uses `-ilc` so `.zshrc` (and the nvm/asdf init it typically sources) runs. - private func readUserShellPath() async -> String? { - do { - let output = try await runShellCommand( - "/bin/zsh", - arguments: ["-ilc", "print -rn -- $PATH"], - injectPath: false - ) - let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } catch { - logger.warning("Failed to read user shell PATH: \(error.localizedDescription)") - return nil - } - } - - /// Locate the bin directory of the most recent nvm-installed Node, if any. - /// Defends against shell readout failure for nvm users. - private func latestNvmBinDirectory(home: String) -> String? { - let root = "\(home)/.nvm/versions/node" - let fm = FileManager.default - guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return nil } - for entry in entries.sorted(by: >) { - let bin = "\(root)/\(entry)/bin" - if fm.isExecutableFile(atPath: "\(bin)/node") { return bin } - } - return nil - } - - /// Build the full environment dictionary for spawned subprocesses, - /// inheriting the GUI environment but with PATH replaced by ``resolvedShellPath()``. - func resolvedEnvironment() async -> [String: String] { - var env = ProcessInfo.processInfo.environment - env["PATH"] = await resolvedShellPath() - return env - } - - // MARK: - Binary Discovery - - /// Well-known paths searched in order before falling back to the shell. - private static var candidatePaths: [String] { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return [ - "/usr/local/bin/claude", - "/opt/homebrew/bin/claude", - "\(home)/.local/bin/claude", - "\(home)/.npm-global/bin/claude", - ] - } - - /// Locate the `claude` binary on this machine. - func findClaudeBinary() async -> String? { - let fm = FileManager.default - - for path in Self.candidatePaths { - // Resolve symlinks before checking - let resolved = (path as NSString).resolvingSymlinksInPath - if fm.fileExists(atPath: resolved) && fm.isExecutableFile(atPath: path) { - logger.info("Found claude binary at \(path, privacy: .public) -> \(resolved, privacy: .public)") - return path - } - } - - // Shell fallback - logger.info("Trying shell fallback to locate claude binary") - do { - let result = try await runShellCommand("/bin/zsh", arguments: ["-ilc", "whence -p claude"]) - let path = result.trimmingCharacters(in: .whitespacesAndNewlines) - if !path.isEmpty, fm.isExecutableFile(atPath: path) { - logger.info("Found claude binary via shell at \(path, privacy: .public)") - return path - } - } catch { - logger.warning("Shell fallback failed: \(error, privacy: .public)") - } - - logger.error("claude binary not found") - return nil - } - - // MARK: - Local Command - - /// Run a local slash command (e.g. "/cost", "/usage") and return stdout. - func runLocalCommand(_ command: String) async throws -> String { - guard let binary = await findClaudeBinary() else { - throw ClaudeError.binaryNotFound - } - - let output = try await runShellCommand(binary, arguments: ["-p", command, "--output-format", "text"]) - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// Run `/context` for a session and parse the used percentage. - /// Returns nil if the session has no context info or parsing fails. - func fetchContextPercentage(sessionId: String, cwd: String) async -> Double? { - guard let binary = await findClaudeBinary() else { return nil } - do { - let output = try await runShellCommand( - binary, - arguments: ["-p", "/context", "--output-format", "text", "--resume", sessionId], - cwd: cwd - ) - // Parse "Tokens: 24.2k / 200k (12%)" pattern - guard let match = output.range(of: #"\((\d+(?:\.\d+)?)%\)"#, options: .regularExpression) else { - return nil - } - let captured = output[match].dropFirst(1).dropLast(2) // remove "(" and "%)" - return Double(captured) - } catch { - logger.warning("Failed to fetch context: \(error.localizedDescription)") - return nil - } - } - - // MARK: - Version Check - - /// Run `claude --version` and return the version string. - func checkVersion() async throws -> String { - guard let binary = await findClaudeBinary() else { - throw ClaudeError.binaryNotFound - } - - let output = try await runShellCommand(binary, arguments: ["--version"]) - let version = output.trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: #"\s*\(Claude Code\)"#, with: "", options: .regularExpression) - - guard !version.isEmpty else { - throw ClaudeError.versionCheckFailed("Empty version output") - } - - logger.info("Claude CLI version: \(version, privacy: .public)") - return version - } - - // MARK: - Title Generation - - /// Generate a short 3–6 word title for a chat from the first user message. - /// Uses a one-shot `claude -p` invocation with Haiku — runs outside of the streaming - /// pipeline and does NOT hit the PermissionServer hook (no `--settings` passed). - /// Returns nil on any failure; callers should keep the placeholder title in that case. - func generateSessionTitle(firstUserMessage: String, model: String = "claude-haiku-4-5-20251001") async -> String? { - guard let binary = await findClaudeBinary() else { return nil } - let trimmedUser = String(firstUserMessage.prefix(500)) - let prompt = """ - Summarize the following user message as a 3-6 word chat title. \ - Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. - - \(trimmedUser) - """ - // Title generation is pure text — no tools needed. Strip MCP servers so a - // user's broken tool schema (e.g. an MCP server returning invalid JSON schema) - // can't blow up the title call with "API Error: 400 tools.NN.custom.input_schema". - let emptyMCPConfigPath = writeEmptyMCPConfig() - var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] - if let emptyMCPConfigPath { - args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) - } - do { - let output = try await runShellCommand(binary, arguments: args) - let cleaned = output - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - // `claude -p` prints API/CLI failures to stdout (e.g. "API Error: 400 ..."). - // Don't promote those to the sidebar title — keep the placeholder instead. - let lower = cleaned.lowercased() - let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] - if errorPrefixes.contains(where: { lower.hasPrefix($0) }) { - logger.warning("Title generation produced an error string; ignoring: \(cleaned.prefix(120))") - return nil - } - return String(cleaned.prefix(80)) - } catch { - logger.warning("Title generation failed: \(error.localizedDescription)") - return nil - } - } - - func generateResponseNotificationSummary(responseText: String, model: String = "claude-haiku-4-5-20251001") async -> String? { - guard let binary = await findClaudeBinary() else { return nil } - let trimmedResponse = String(responseText.prefix(4000)) - let prompt = """ - Summarize the following assistant response for a macOS notification. \ - Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. \ - No markdown. - - \(trimmedResponse) - """ - let emptyMCPConfigPath = writeEmptyMCPConfig() - var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] - if let emptyMCPConfigPath { - args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) - } - do { - let output = try await runShellCommand(binary, arguments: args) - let cleaned = output - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - let lower = cleaned.lowercased() - let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] - if errorPrefixes.contains(where: { lower.hasPrefix($0) }) { - logger.warning("Notification summary produced an error string; ignoring: \(cleaned.prefix(120))") - return nil - } - return String(cleaned.prefix(180)) - } catch { - logger.warning("Notification summary generation failed: \(error.localizedDescription)") - return nil - } - } - - func generateThreadSummary( - previousSummary: String?, - userMessage: String, - finalResponse: String, - model: String = "claude-haiku-4-5-20251001" - ) async -> String? { - let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) - let prompt = """ - Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. - Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. - Reply with only the updated summary. - - Previous summary: - \((previous?.isEmpty == false) ? previous! : "None") - - Latest user request: - \(String(userMessage.prefix(2000))) - - Final assistant response: - \(String(finalResponse.prefix(4000))) - """ - return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) - } - - func generateMemoryOperations( - existingMemories: [(id: String, content: String)], - userMessage: String, - finalResponse: String, - model: String = "claude-haiku-4-5-20251001" - ) async -> String? { - let prompt = OpenAISummarizationService.memoryExtractionPrompt( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse - ) - return await generatePlainSummary(prompt: prompt, model: model, limit: 3000) - } - - func generateBranchBriefing( - threadSummaries: [(title: String, summary: String)], - model: String = "claude-haiku-4-5-20251001" - ) async -> String? { - guard !threadSummaries.isEmpty else { return nil } - let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) - return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) - } - - func generateCommitMessage( - diff: String, - fileSummary: String, - model: String = "claude-haiku-4-5-20251001" - ) async -> String? { - // Caller (AppState) has already applied a provider-aware budget; this - // is just an upper bound to guard against accidental misuse. - let trimmedDiff = String(diff.prefix(20_000)) - let prompt = """ - Write a Git commit message for the staged changes below in the Conventional Commits format. - - Format rules (MUST follow exactly): - - First line: `(): ` — subject must be under 72 characters, lowercase imperative mood, no trailing period. - - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. - - After the subject, an optional blank line followed by 1-3 short bullet points explaining the WHY (each starting with "- "). - - Do NOT use markdown headings (no `#`, `##`). - - Do NOT wrap the message in quotes or code fences. - - Do NOT prefix with anything else; the very first characters must be the type. - - Example output: - feat(git): add commit message generator - - - reuse summarization providers for on-device generation - - support staged diff context - - Staged files: - \(fileSummary) - - Staged diff: - \(trimmedDiff) - """ - return await generatePlainSummary(prompt: prompt, model: model, limit: 1000) - } - - private func generatePlainSummary(prompt: String, model: String, limit: Int) async -> String? { - guard let binary = await findClaudeBinary() else { return nil } - let emptyMCPConfigPath = writeEmptyMCPConfig() - var args: [String] = ["-p", prompt, "--output-format", "text", "--model", model] - if let emptyMCPConfigPath { - args.append(contentsOf: ["--strict-mcp-config", "--mcp-config", emptyMCPConfigPath]) - } - do { - let output = try await runShellCommand(binary, arguments: args) - return cleanGeneratedSummary(output, limit: limit) - } catch { - logger.warning("Summary generation failed: \(error.localizedDescription)") - return nil - } - } - - private func cleanGeneratedSummary(_ raw: String, limit: Int) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - let lower = cleaned.lowercased() - let errorPrefixes = ["api error", "error:", "execution error", "request failed", "claude error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) - } - - /// Write a one-off MCP config file (with no servers) used by the title-generation - /// call so it doesn't inherit user-level MCP servers. Returns nil on I/O failure; - /// caller falls back to the default config. - private func writeEmptyMCPConfig() -> String? { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent("RxCode", isDirectory: true) - let path = dir.appendingPathComponent("empty-mcp.json") - do { - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try Data("{\"mcpServers\":{}}".utf8).write(to: path, options: .atomic) - return path.path - } catch { - logger.warning("Failed to write empty MCP config: \(error.localizedDescription)") - return nil - } - } - - // MARK: - Send (spawn + stream) - - /// Spawn the CLI and return a stream of parsed events. - /// - /// Architecture: a single `Task.detached` reads stdout line-by-line, - /// decodes NDJSON, and yields `StreamEvent`s. No intermediate streams, - /// no shared-actor scheduling issues. - /// - /// Multiple concurrent streams are managed independently via `streamId`. - func send( - streamId: UUID, - prompt: String, - cwd: String, - sessionId: String? = nil, - model: String? = nil, - effort: String? = nil, - hookSettingsPath: String? = nil, - mcpConfigPath: String? = nil, - extraSystemPrompt: String? = nil, - permissionMode: PermissionMode = .default - ) -> AsyncStream { - let stdin = Pipe() - let stdout = Pipe() - let stderr = Pipe() - - let log = self.logger - let currentStreamId = streamId - - readStderr(stderr, streamId: currentStreamId) - - return AsyncStream { continuation in - let task = Task.detached { [weak self] in - guard let self else { - continuation.finish() - return - } - - // Spawn process (hops to ClaudeService actor for state) - do { - try await self.spawnProcess( - streamId: streamId, - prompt: prompt, - cwd: cwd, - sessionId: sessionId, - model: model, - effort: effort, - hookSettingsPath: hookSettingsPath, - mcpConfigPath: mcpConfigPath, - extraSystemPrompt: extraSystemPrompt, - permissionMode: permissionMode, - stdinPipe: stdin, - stdoutPipe: stdout, - stderrPipe: stderr, - onProcessExit: { - // Wait 2 seconds after process exit to flush remaining buffers - // before finishing the stream. continuation.finish() is thread-safe and - // idempotent, so duplicate calls on normal exit are safe. - DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { - continuation.finish() - } - } - ) - } catch { - log.error("[Stream] spawn failed: \(error.localizedDescription)") - continuation.finish() - return - } - - // Read stdout line-by-line — ends naturally at EOF - var parsedCount = 0 - var failedCount = 0 - let decoder = JSONDecoder() - log.info("[Stream] starting stdout read loop") - - var rawLineCount = 0 - var capturedSessionId: String? - do { - for try await line in stdout.fileHandleForReading.bytes.lines { - guard !line.isEmpty else { continue } - guard let data = line.data(using: .utf8) else { continue } - - rawLineCount += 1 - // Diagnostic logging of raw NDJSON — full content for first 30 lines, then type field only - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let type = (json["type"] as? String) ?? "?" - if rawLineCount <= 30 { - log.info("[Stream:RAW] #\(rawLineCount) type=\(type) line=\(line.prefix(600))") - } else if type == "stream_event" || rawLineCount % 50 == 0 { - log.info("[Stream:RAW] #\(rawLineCount) type=\(type)") - } - if capturedSessionId == nil, - let sid = (json["session_id"] as? String) ?? (json["sessionId"] as? String) { - capturedSessionId = sid - Task { await self.recordSessionId(streamId: streamId, sessionId: sid) } - } - } else if rawLineCount <= 30 { - log.info("[Stream:RAW] #\(rawLineCount) non-JSON line=\(line.prefix(600))") - } - - do { - let event = try decoder.decode(StreamEvent.self, from: data) - parsedCount += 1 - continuation.yield(event) - } catch { - failedCount += 1 - // Yield raw string so partial events still reach the UI - continuation.yield(.unknown(line)) - if failedCount <= 5 { - log.warning("[Stream] parse failed #\(failedCount): \(line.prefix(200))") - } - } - } - } catch { - log.warning("[Stream] stdout read error: \(error.localizedDescription)") - } - - log.info("[Stream] stdout ended (parsed=\(parsedCount), failed=\(failedCount))") - continuation.finish() - } - - continuation.onTermination = { reason in - log.info("[Stream] terminated (reason=\(String(describing: reason)))") - task.cancel() - // Close the pipe after the stream ends to unblock the bytes.lines read. - // onTermination is called after finish(), so there is no data loss. - stdout.fileHandleForReading.closeFile() - } - } - } - - // MARK: - Descendant tracker - - /// Start a background poller that periodically snapshots every descendant of `root` - /// and merges them into `trackedDescendants[streamId]`. The accumulated set is the - /// safety net for descendants that briefly exist as findable children of `root` - /// before detaching themselves (via `setsid`/`setpgid`) and being reparented away. - /// - /// Cancelled in `removeProcess` when the stream ends. - private func startDescendantTracker(streamId: UUID, root: pid_t) { - // Capture sid once at startup — getsid() on a live root returns the session id, - // which equals `root` itself since we spawned with POSIX_SPAWN_SETSID. - let sid = getsid(root) - let task = Task.detached { [weak self] in - while !Task.isCancelled { - let pids = Self.descendantPids(of: root, sid: sid) - if !pids.isEmpty { - await self?.mergeTrackedDescendants(streamId: streamId, pids: pids) - } - // 500ms is a balance: short enough to catch transient ppid links before - // an intermediate parent dies and reparenting hides the child, while not - // burning measurable CPU on the `ps` invocation (~5ms per snapshot). - try? await Task.sleep(nanoseconds: 500_000_000) - } - } - descendantTrackers[streamId] = task - } - - private func mergeTrackedDescendants(streamId: UUID, pids: [pid_t]) { - trackedDescendants[streamId, default: []].formUnion(pids) - } - - /// Union of the live snapshot and every descendant ever seen for this stream. - /// `kill(pid, 0)` filters out already-reaped pids so signals only target live ones. - private func allKnownDescendants(streamId: UUID, root: pid_t, sid: pid_t) -> [pid_t] { - var union = trackedDescendants[streamId] ?? [] - union.formUnion(Self.descendantPids(of: root, sid: sid)) - return union.filter { kill($0, 0) == 0 } - } - - // MARK: - Cancel / Finalize - - /// User-initiated stop. Send SIGINT to the entire process group so subagent - /// children die alongside the parent. Escalate to SIGKILL after 5 seconds. - func cancel(streamId: UUID) { - guard let pgid = streamPGIDs[streamId] else { return } - // Capture sid while the root is still alive — getsid(pid) returns -1 once - // the process is fully reaped, but the value is needed for the SIGKILL re-snapshot. - let sid = getsid(pgid) - let escapees = allKnownDescendants(streamId: streamId, root: pgid, sid: sid) - - logger.info("Sending SIGINT to claude pgid \(pgid) escapees=\(escapees) (stream=\(streamId))") - killpg(pgid, SIGINT) - for pid in escapees { kill(pid, SIGINT) } - - let log = logger - Task.detached { [weak self] in - try? await Task.sleep(nanoseconds: 5_000_000_000) - // Re-snapshot before SIGKILL — picks up anything that emerged in the 5s window. - let finalEscapees = await self?.allKnownDescendants(streamId: streamId, root: pgid, sid: sid) ?? [] - // killpg/kill on a fully-dead target returns ESRCH — harmless. Send unconditionally - // to cover any subagent that ignored SIGINT or escaped the process group. - killpg(pgid, SIGKILL) - for pid in finalEscapees { kill(pid, SIGKILL) } - log.debug("Cancel SIGKILL pgid=\(pgid) escapees=\(finalEscapees)") - } - } - - /// Thread-finished sweep. Called after the `result` event (and from the exit - /// handler) to guarantee no subagent process outlives the parent CLI. - /// - /// MCP servers and Node children spawned `detached: true` may escape the - /// parent's process group (via `setsid`/`setpgid`), so `killpg` alone is not - /// enough. Snapshot the descendant tree before signaling and SIGKILL each - /// escapee individually as the safety net — running in a detached Task so - /// actor contention can't delay the escalation. - func finalize(streamId: UUID) { - // Claim the entry atomically so a second concurrent caller (e.g. AppState's - // result-driven finalize racing with the waitpid-driven handleProcessExit) - // becomes a no-op instead of re-signaling an already-reaped pgid. - guard let pgid = streamPGIDs.removeValue(forKey: streamId) else { return } - // Capture sid while the root is still alive — see cancel() for rationale. - let sid = getsid(pgid) - let escapees = allKnownDescendants(streamId: streamId, root: pgid, sid: sid) - - logger.info("Finalizing stream — pgid=\(pgid) sid=\(sid) escapees=\(escapees) stream=\(streamId)") - killpg(pgid, SIGTERM) - for pid in escapees { kill(pid, SIGTERM) } - - let log = logger - Task.detached { [weak self] in - try? await Task.sleep(nanoseconds: 1_500_000_000) - // Re-snapshot before SIGKILL. By this time the root may have already - // exited; the accumulated `trackedDescendants` set is what catches - // session-escaped, reparented processes that no live ps query can find. - let finalEscapees = await self?.allKnownDescendants(streamId: streamId, root: pgid, sid: sid) ?? [] - killpg(pgid, SIGKILL) - for pid in finalEscapees { kill(pid, SIGKILL) } - log.debug("Finalize SIGKILL pgid=\(pgid) escapees=\(finalEscapees)") - } - } - - /// Find every descendant pid of `root` (not including `root`). Combines two - /// strategies for maximum coverage: - /// - /// 1. **Parent walk** — BFS via `ps -Ao pid,ppid`. Catches everything reachable - /// through ppid links from `root`. Works for live, non-reparented trees but - /// breaks once an intermediate parent dies and its children are reparented - /// to launchd (ppid=1). - /// 2. **Session match** — for each running pid, compare `getsid(pid)` to the - /// passed-in `sid`. Catches descendants that broke out of the pgid (called - /// `setpgid`) and whose ppid chain was severed by reparenting, as long as - /// they did not call `setsid` themselves. Relies on the root having been - /// spawned with `POSIX_SPAWN_SETSID` so that `sid == root`. - /// - /// Pass `sid: 0` to skip the session match (e.g., if `getsid(root)` already - /// returned an error). Callers should capture `sid` while the root is alive, - /// since `getsid()` on a reaped pid returns -1. - private static func descendantPids(of root: pid_t, sid: pid_t) -> [pid_t] { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/bin/ps") - proc.arguments = ["-Ao", "pid,ppid"] - let pipe = Pipe() - proc.standardOutput = pipe - proc.standardError = FileHandle.nullDevice - do { - try proc.run() - proc.waitUntilExit() - } catch { - return [] - } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let text = String(data: data, encoding: .utf8) else { return [] } - - var childrenByParent: [pid_t: [pid_t]] = [:] - var allPids: [pid_t] = [] - for line in text.split(separator: "\n").dropFirst() { - let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) - guard parts.count >= 2, - let pid = pid_t(parts[0]), - let ppid = pid_t(parts[1]) else { continue } - childrenByParent[ppid, default: []].append(pid) - allPids.append(pid) - } - - var result = Set() - - // (1) BFS via ppid links — works while parent chain is intact. - var queue: [pid_t] = [root] - while !queue.isEmpty { - let next = queue.removeFirst() - guard let children = childrenByParent[next] else { continue } - for child in children where child != root { - if result.insert(child).inserted { - queue.append(child) - } - } - } - - // (2) Session-id match — survives reparenting; misses processes that - // called setsid themselves (rare for CLI subagents). - if sid > 0 { - for pid in allPids where pid != root { - if getsid(pid) == sid { - result.insert(pid) - } - } - } - - return Array(result) - } - - // MARK: - Private Helpers - - /// Extra system-prompt text appended via `--append-system-prompt`. Tells the - /// agent about the IDE-provided `rxcode-ide` MCP server, which lets it talk - /// to agents in other RxCode projects/threads, introspect editor state, and - /// recall/store durable cross-session memories. - private static let ideToolsSystemPrompt = """ - # RxCode IDE tools - - You are running inside RxCode, a desktop IDE that hosts multiple projects, \ - each with its own chat threads and agents. RxCode injects a local MCP \ - server named `rxcode-ide`; its tools are exposed to you with the \ - `mcp__rxcode-ide__` prefix. Use them to coordinate with other agents \ - (multi-agent talk) and to read editor state: - - - `mcp__rxcode-ide__ide__get_projects` — list every project registered in \ - RxCode, so you can discover sibling projects to read or message. - - `mcp__rxcode-ide__ide__get_threads` — list or natural-language search \ - chat threads across projects (returns AI summaries, and ranked snippets \ - when a query is given). - - `mcp__rxcode-ide__ide__get_thread_messages` — fetch the message history \ - of a specific thread by id. - - `mcp__rxcode-ide__ide__send_to_thread` — talk to another project's agent: \ - send a prompt to an existing thread (`thread_id`) or start a new thread in \ - a project (`project_id`). This triggers a real agent run that may consume \ - tokens; it returns the other agent's reply. - - `mcp__rxcode-ide__ide__get_running_jobs` / `ide__get_job_output` — \ - inspect run-profile jobs (dev servers, scripts) executing in the IDE. - - `mcp__rxcode-ide__ide__get_usage` — current rate-limit / token usage. - - Reach for these when a task spans projects — e.g. checking what another \ - project already did, or delegating a subtask to its agent — rather than \ - guessing. Prefer reading threads first; only use `ide__send_to_thread` when \ - you actually need another agent to act, since it costs tokens. - - RxCode also persists durable memories — stable user preferences, project \ - facts, and decisions — across sessions. Use these tools to recall and \ - store them: - - - `mcp__rxcode-ide__ide__memory_search` — before starting a task, search \ - for saved preferences, facts, or decisions relevant to it instead of \ - asking the user to repeat themselves. - - `mcp__rxcode-ide__ide__memory_add` — when the user states a stable \ - preference, project fact, or decision ("remember…", "from now on…", \ - "always…"), save it. Set `kind` (`preference`/`fact`/`decision`) and \ - `scope` (`global` for cross-project, `project` for repo-specific). - - `mcp__rxcode-ide__ide__memory_update` — when saved information changes, \ - update the existing entry by `id` rather than adding a duplicate. - - `mcp__rxcode-ide__ide__memory_delete` — remove a memory by `id` when it \ - is no longer valid. - - Only store stable, reusable information in memory — not transient task \ - details. - """ - - /// Build arguments array for the CLI invocation. - /// - /// The user prompt is NOT a CLI argument — it is written to stdin as a JSON - /// user message (see `spawnProcess`) because we run the CLI with - /// `--input-format stream-json`. - private func buildArguments( - sessionId: String?, - model: String?, - effort: String?, - hookSettingsPath: String?, - mcpConfigPath: String?, - extraSystemPrompt: String?, - permissionMode: PermissionMode - ) -> [String] { - var args: [String] = [ - "-p", - "--input-format", "stream-json", - "--output-format", "stream-json", - "--verbose", - "--include-partial-messages", - ] - - if permissionMode != .default { - args += ["--permission-mode", permissionMode.rawValue] - } - - if !permissionMode.skipsHookPipeline { - // Pre-approve safe tools that don't need to go through hooks via --allowedTools. - // This eliminates HTTP round-trips from internal agent mechanics like Read/Grep/Task, - // since no approval UI is shown for these. - let safeTools = [ - "Read", "Glob", "Grep", "LS", - "TodoRead", "TodoWrite", - "Agent", "Task", "TaskOutput", - "Notebook", "NotebookEdit", - "WebSearch", "WebFetch", - ] - args += ["--allowedTools", safeTools.joined(separator: ",")] - } - - if let hookSettingsPath { - args += ["--settings", hookSettingsPath] - } - - if let mcpConfigPath { - args += ["--strict-mcp-config", "--mcp-config", mcpConfigPath] - } - - // Assemble the system-prompt additions for this turn into a single - // `--append-system-prompt` value (the CLI honours one occurrence): - // - IDE tools blurb, when the `rxcode-ide` MCP server is wired in. - // - Caller-supplied context, e.g. the current branch briefing. - var systemPromptSections: [String] = [] - if mcpConfigPath != nil { - systemPromptSections.append(Self.ideToolsSystemPrompt) - } - if let extraSystemPrompt, !extraSystemPrompt.isEmpty { - systemPromptSections.append(extraSystemPrompt) - } - if !systemPromptSections.isEmpty { - args += ["--append-system-prompt", systemPromptSections.joined(separator: "\n\n")] - } - - if let sessionId { - args += ["--resume", sessionId] - } - - if let model { - args += ["--model", model] - } - - if let effort { - args += ["--effort", effort] - } - - // With `--input-format stream-json`, the prompt is sent via stdin as a JSON - // user message (see spawnProcess) rather than as a CLI argument. - return args - } - - /// Launch the streaming `claude` CLI in its own process group via `posix_spawn`. - /// - /// Foundation's `Process` does not expose `posix_spawnattr_t`, so the streaming - /// invocation goes through the raw POSIX API. The new group means the leader's - /// pid is also its pgid — `killpg(pid, ...)` reaches every subagent the CLI - /// spawns via the Task tool. - private func spawnProcess( - streamId: UUID, - prompt: String, - cwd: String, - sessionId: String?, - model: String?, - effort: String? = nil, - hookSettingsPath: String?, - mcpConfigPath: String?, - extraSystemPrompt: String? = nil, - permissionMode: PermissionMode = .default, - stdinPipe: Pipe, - stdoutPipe: Pipe, - stderrPipe: Pipe, - onProcessExit: (@Sendable () -> Void)? = nil - ) async throws { - guard let binary = await findClaudeBinary() else { - throw ClaudeError.binaryNotFound - } - - let arguments = buildArguments( - sessionId: sessionId, - model: model, - effort: effort, - hookSettingsPath: hookSettingsPath, - mcpConfigPath: mcpConfigPath, - extraSystemPrompt: extraSystemPrompt, - permissionMode: permissionMode - ) - let environment = await resolvedEnvironment() - - let stdinReadFD = stdinPipe.fileHandleForReading.fileDescriptor - let stdoutWriteFD = stdoutPipe.fileHandleForWriting.fileDescriptor - let stderrWriteFD = stderrPipe.fileHandleForWriting.fileDescriptor - - let pid: pid_t - do { - pid = try Self.spawnInNewProcessGroup( - executable: binary, - arguments: arguments, - environment: environment, - cwd: cwd, - stdinReadFD: stdinReadFD, - stdoutWriteFD: stdoutWriteFD, - stderrWriteFD: stderrWriteFD - ) - } catch { - logger.error("Failed to spawn claude: \(error, privacy: .public)") - throw ClaudeError.spawnFailed(error.localizedDescription) - } - - // Parent must release the child-owned pipe ends so EOF propagates correctly. - try? stdinPipe.fileHandleForReading.close() - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - - // Keep stdin open for stream-json input protocol. closed in `closeStdin(streamId:)` - // after the `result` event so the CLI can flush remaining output and exit. - let stdinHandle = stdinPipe.fileHandleForWriting - self.streamPGIDs[streamId] = pid - self.stdinHandles[streamId] = stdinHandle - self.startDescendantTracker(streamId: streamId, root: pid) - - // Send the initial user prompt as an NDJSON user message. - let userMessage: [String: Any] = [ - "type": "user", - "message": [ - "role": "user", - "content": [ - ["type": "text", "text": prompt] - ] - ] - ] - try Self.writeJSONLine(userMessage, to: stdinHandle) - - logger.info( - "Spawned claude process pid=\(pid) pgid=\(pid) cwd=\(cwd, privacy: .public) stream=\(streamId)" - ) - - // Wait for the parent CLI to exit on a background thread, then sweep - // the process group to reap any subagent children that outlived it. - let log = logger - let cwdCopy = cwd - Task.detached { [weak self] in - var status: Int32 = 0 - var rc: pid_t = 0 - repeat { - rc = waitpid(pid, &status, 0) - } while rc < 0 && errno == EINTR - - log.info( - "claude process exited — pid=\(pid) raw_status=\(status) stream=\(streamId)" - ) - - await self?.handleProcessExit(streamId: streamId, cwd: cwdCopy) - onProcessExit?() - } - } - - /// Run after the parent CLI's `waitpid` returns. Sweep any surviving subagents - /// in the same process group, then drop bookkeeping for the stream. - private func handleProcessExit(streamId: UUID, cwd: String) async { - finalize(streamId: streamId) - removeProcess(streamId: streamId) - if let sid = consumeSessionId(streamId: streamId) { - await cliStore.exposeToPicker(sid: sid, cwd: cwd) - } - } - - /// Spawn `executable` with `arguments` in its own process group. - /// Returns the child pid (== pgid since `POSIX_SPAWN_SETPGROUP` with group 0 - /// makes the child its own group leader). - private static func spawnInNewProcessGroup( - executable: String, - arguments: [String], - environment: [String: String], - cwd: String, - stdinReadFD: Int32, - stdoutWriteFD: Int32, - stderrWriteFD: Int32 - ) throws -> pid_t { - var fileActions: posix_spawn_file_actions_t? - guard posix_spawn_file_actions_init(&fileActions) == 0 else { - throw ClaudeError.spawnFailed("posix_spawn_file_actions_init failed") - } - defer { posix_spawn_file_actions_destroy(&fileActions) } - - _ = posix_spawn_file_actions_adddup2(&fileActions, stdinReadFD, 0) - _ = posix_spawn_file_actions_adddup2(&fileActions, stdoutWriteFD, 1) - _ = posix_spawn_file_actions_adddup2(&fileActions, stderrWriteFD, 2) - - let chdirRC = cwd.withCString { cwdPtr in - posix_spawn_file_actions_addchdir_np(&fileActions, cwdPtr) - } - if chdirRC != 0 { - throw ClaudeError.spawnFailed("posix_spawn_file_actions_addchdir_np failed: \(chdirRC)") - } - - var attr: posix_spawnattr_t? - guard posix_spawnattr_init(&attr) == 0 else { - throw ClaudeError.spawnFailed("posix_spawnattr_init failed") - } - defer { posix_spawnattr_destroy(&attr) } - - // SETSID makes the child a new session leader (sid == pid == pgid). Session id - // is preserved across reparenting, so we can locate descendants via getsid() - // even after an intermediate parent has died and orphans were reparented to - // launchd. Pure SETPGROUP would not survive reparenting on its own. - _ = posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETSID)) - - var argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } - argv.append(nil) - defer { for p in argv { if let p = p { free(p) } } } - - let envEntries = environment.map { "\($0.key)=\($0.value)" } - var envp: [UnsafeMutablePointer?] = envEntries.map { strdup($0) } - envp.append(nil) - defer { for p in envp { if let p = p { free(p) } } } - - var pid: pid_t = 0 - let rc = executable.withCString { execPath in - posix_spawn(&pid, execPath, &fileActions, &attr, argv, envp) - } - if rc != 0 { - let msg = String(cString: strerror(rc)) - throw ClaudeError.spawnFailed("posix_spawn failed: \(msg) (\(rc))") - } - return pid - } - - // MARK: - Stdin Writer - - /// Serialize a dictionary to JSON and write to stdin as one NDJSON line. - /// Non-isolated to allow use from `spawnProcess` after `try proc.run()`. - private static func writeJSONLine(_ object: [String: Any], to handle: FileHandle) throws { - let data = try JSONSerialization.data(withJSONObject: object, options: []) - handle.write(data) - handle.write(Data([0x0A])) // newline - } - - /// Close stdin for an active stream. Call this after receiving the `result` event - /// so the CLI process exits cleanly once it has flushed all remaining output. - func closeStdin(streamId: UUID) { - guard let handle = stdinHandles.removeValue(forKey: streamId) else { return } - do { - try handle.close() - logger.info("Closed stdin for stream=\(streamId)") - } catch { - logger.warning("closeStdin error for stream=\(streamId): \(error.localizedDescription)") - } - } - - /// Remove a stream's bookkeeping from within actor isolation, called from - /// the waitpid-driven exit handler. - private func removeProcess(streamId: UUID) { - streamPGIDs.removeValue(forKey: streamId) - descendantTrackers.removeValue(forKey: streamId)?.cancel() - // Retain `trackedDescendants[streamId]` long enough for the SIGKILL re-snapshot - // in cancel/finalize (those run in detached tasks after this method). Clear it - // after a short delay so the actor doesn't accumulate stale entries. - let clearKey = streamId - Task { [weak self] in - try? await Task.sleep(nanoseconds: 6_000_000_000) - await self?.clearTrackedDescendants(streamId: clearKey) - } - // If stdin is still open (e.g. abnormal exit before `result`), release the handle. - if let handle = stdinHandles.removeValue(forKey: streamId) { - try? handle.close() - } - } - - private func clearTrackedDescendants(streamId: UUID) { - trackedDescendants.removeValue(forKey: streamId) - } - - private func recordSessionId(streamId: UUID, sessionId: String) { - streamSessionIds[streamId] = sessionId - } - - private func consumeSessionId(streamId: UUID) -> String? { - streamSessionIds.removeValue(forKey: streamId) - } - - /// Read stderr asynchronously, log each line, and buffer for error reporting. - private nonisolated func readStderr(_ pipe: Pipe, streamId: UUID) { - let log = logger - pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - pipe.fileHandleForReading.readabilityHandler = nil - return - } - if let text = String(data: data, encoding: .utf8) { - for line in text.split(separator: "\n") { - log.debug("[stderr] \(line, privacy: .public)") - } - Task { await self?.appendStderr(text, for: streamId) } - } - } - } - - /// Append text to the stderr buffer - private func appendStderr(_ text: String, for streamId: UUID) { - stderrBuffers[streamId, default: ""] += text - } - - /// Consume and return the stderr buffer for a given stream - func consumeStderr(for streamId: UUID) -> String? { - guard let buffer = stderrBuffers.removeValue(forKey: streamId), - !buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return nil - } - return buffer.trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// Run a simple command and return its stdout as a String. - /// Uses async termination handling to avoid blocking the actor's cooperative thread. - /// - /// `injectPath` controls whether the spawned process receives the resolved - /// shell PATH. Set to `false` when this method is itself used to *resolve* - /// the shell PATH, to break the chicken-and-egg loop. - private func runShellCommand( - _ command: String, - arguments: [String] = [], - cwd: String? = nil, - injectPath: Bool = true - ) async throws -> String { - let proc = Process() - let pipe = Pipe() - - proc.executableURL = URL(fileURLWithPath: command) - proc.arguments = arguments - proc.standardOutput = pipe - proc.standardError = FileHandle.nullDevice - proc.environment = injectPath - ? await resolvedEnvironment() - : ProcessInfo.processInfo.environment - if let cwd { proc.currentDirectoryURL = URL(fileURLWithPath: cwd) } - - try proc.run() - - // Wait for process exit asynchronously instead of blocking - await withCheckedContinuation { (continuation: CheckedContinuation) in - proc.terminationHandler = { _ in - continuation.resume() - } - } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - // MARK: - Cleanup /// Tear down any resources held by the service. Called on app termination diff --git a/RxCode/Services/CodexAppServer+Parsing.swift b/RxCode/Services/CodexAppServer+Parsing.swift new file mode 100644 index 0000000..1489873 --- /dev/null +++ b/RxCode/Services/CodexAppServer+Parsing.swift @@ -0,0 +1,328 @@ +import Foundation +import RxCodeCore +import os + +extension CodexAppServer { + func toolName(from item: [String: JSONValue]) -> String? { + if let type = Self.firstString(in: item, keys: ["type", "kind"]) { + let normalizedType = type.lowercased() + if normalizedType.contains("command") { return "Bash" } + if normalizedType.contains("file") || normalizedType.contains("patch") { return "Edit" } + if normalizedType.contains("message") { return "message" } + return type + } + guard let name = Self.firstString(in: item, keys: ["name", "toolName"]) else { return nil } + return name.lowercased().contains("message") ? "message" : name + } + + static func parseModels(from value: JSONValue) -> [AgentModel] { + let root = value.objectValue + let rawModels = root?["data"]?.arrayValue ?? root?["models"]?.arrayValue ?? root?["items"]?.arrayValue ?? value.arrayValue ?? [] + return rawModels.compactMap { entry in + if let id = entry.stringValue { + return AgentModel(provider: .codex, id: id, displayName: AppStateModelFormatter.codexDisplayName(id), description: "Codex model served by the Codex app-server.") + } + guard let object = entry.objectValue, + let id = firstString(in: object, keys: ["id", "slug", "model", "name"]) else { return nil } + if object["hidden"]?.boolValue == true || object["visibility"]?.stringValue == "hidden" { + return nil + } + let displayName = firstString(in: object, keys: ["displayName", "display_name", "name"]) ?? AppStateModelFormatter.codexDisplayName(id) + let description = firstString(in: object, keys: ["description", "detail"]) ?? "Codex model served by the Codex app-server." + return AgentModel(provider: .codex, id: id, displayName: displayName, description: description) + } + } + + static func idString(_ value: JSONValue?) -> String? { + if let s = value?.stringValue { return s } + if let n = value?.numberValue { + if n.rounded() == n { return String(Int(n)) } + return String(n) + } + return nil + } + + static func threadId(from value: JSONValue) -> String? { + if let object = value.objectValue { + if let id = firstString(in: object, keys: ["threadId", "thread_id", "id"]) { return id } + if let nested = object["thread"]?.objectValue { + return firstString(in: nested, keys: ["threadId", "thread_id", "id"]) + } + } + return value.stringValue + } + + static func firstString(in object: [String: JSONValue], keys: [String]) -> String? { + for key in keys { + if let value = object[key]?.stringValue { return value } + if let number = object[key]?.numberValue { return String(number) } + } + return nil + } + + static func parseCodexRateLimits(from value: JSONValue) -> RateLimitUsage? { + guard let snapshot = codexRateLimitSnapshot(from: value) else { return nil } + let windows = [ + parseCodexRateLimitWindow(snapshot["primary"]), + parseCodexRateLimitWindow(snapshot["secondary"]) + ].compactMap { $0 } + + guard !windows.isEmpty else { return nil } + let fiveHour = windows.first { $0.durationMinutes == 300 } ?? windows.first + let twentyFourHour = windows.first { $0.durationMinutes == 1_440 } + ?? windows.first { $0.durationMinutes != fiveHour?.durationMinutes } + ?? windows.dropFirst().first + + return RateLimitUsage( + fiveHourPercent: fiveHour?.percent ?? 0, + sevenDayPercent: 0, + twentyFourHourPercent: twentyFourHour?.percent, + fiveHourResetsAt: fiveHour?.resetsAt, + sevenDayResetsAt: nil, + twentyFourHourResetsAt: twentyFourHour?.resetsAt + ) + } + + static func codexRateLimitSnapshot(from value: JSONValue) -> [String: JSONValue]? { + guard let object = value.objectValue else { return nil } + + if let byLimitId = object["rateLimitsByLimitId"]?.objectValue { + if let codex = byLimitId["codex"]?.objectValue { + return codex + } + for snapshot in byLimitId.values.compactMap(\.objectValue) { + if firstString(in: snapshot, keys: ["limitId", "limit_id"]) == "codex" { + return snapshot + } + } + } + + if let rateLimits = object["rateLimits"]?.objectValue { + return rateLimits + } + + if object["primary"] != nil || object["secondary"] != nil { + return object + } + + return nil + } + + static func parseCodexRateLimitWindow(_ value: JSONValue?) -> CodexRateLimitWindow? { + guard let object = value?.objectValue else { return nil } + let percent = firstDouble(in: object, keys: ["usedPercent", "used_percent", "utilization"]) + let duration = firstOptionalInt(in: object, keys: ["windowDurationMins", "window_duration_mins", "windowMinutes"]) + return CodexRateLimitWindow( + percent: percent, + resetsAt: parseUnixOrISODate(object["resetsAt"] ?? object["resets_at"]), + durationMinutes: duration + ) + } + + static func firstDouble(in object: [String: JSONValue], keys: [String]) -> Double { + for key in keys { + if let value = object[key]?.numberValue { return value } + if let value = object[key]?.stringValue, let doubleValue = Double(value) { return doubleValue } + } + return 0 + } + + static func firstOptionalInt(in object: [String: JSONValue], keys: [String]) -> Int? { + for key in keys { + if let value = object[key]?.numberValue { return Int(value) } + if let value = object[key]?.stringValue, let intValue = Int(value) { return intValue } + } + return nil + } + + static func parseUnixOrISODate(_ value: JSONValue?) -> Date? { + if let number = value?.numberValue { + let seconds = number > 1_000_000_000_000 ? number / 1_000 : number + return Date(timeIntervalSince1970: seconds) + } + guard let string = value?.stringValue else { return nil } + if let number = Double(string) { + let seconds = number > 1_000_000_000_000 ? number / 1_000 : number + return Date(timeIntervalSince1970: seconds) + } + return ISO8601DateFormatter().date(from: string) + } + + static func usageInfo(from object: [String: JSONValue]) -> UsageInfo? { + let params = object["params"]?.objectValue ?? object + let usageKeys = ["usage", "tokenUsage", "token_usage", "totalUsage", "total_usage", "metrics"] + let usage = firstObject(in: params, keys: [ + "usage", "tokenUsage", "token_usage", "totalUsage", "total_usage", "metrics" + ]) ?? firstNestedObject(in: .object(params), keys: usageKeys) ?? params + + var inputTokens = firstInt(in: usage, keys: [ + "input_tokens", "inputTokens", "prompt_tokens", "promptTokens" + ]) + var outputTokens = firstInt(in: usage, keys: [ + "output_tokens", "outputTokens", "completion_tokens", "completionTokens" + ]) + outputTokens += firstInt(in: usage, keys: [ + "reasoning_output_tokens", "reasoningOutputTokens" + ]) + let cacheCreationTokens = firstInt(in: usage, keys: [ + "cache_creation_input_tokens", "cacheCreationInputTokens", "cacheWriteInputTokens", + "cached_creation_tokens", "cachedCreationTokens" + ]) + let explicitCacheReadTokens = firstInt(in: usage, keys: [ + "cache_read_input_tokens", "cacheReadInputTokens", "cached_input_tokens", + "cachedInputTokens", "cache_read_tokens", "cacheReadTokens" + ]) + let cacheReadTokens = explicitCacheReadTokens > 0 ? explicitCacheReadTokens : nestedInputCacheReadTokens(in: usage) + + let total = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + let totalTokens = firstInt(in: usage, keys: ["total_tokens", "totalTokens"]) + if total == 0, totalTokens > 0 { + inputTokens = totalTokens + } + + guard inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens > 0 else { + return nil + } + return UsageInfo( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens + ) + } + + static func liveTokenUsage(from params: [String: JSONValue]) -> UsageInfo? { + guard let outputTokens = tokenUsageOutputTokens(from: params), + outputTokens > 0 else { return nil } + return UsageInfo( + inputTokens: 0, + outputTokens: outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + ) + } + + static func tokenUsageOutputTokens(from params: [String: JSONValue]) -> Int? { + let usage = tokenUsageSummary(from: params) + let outputTokens = firstInt(in: usage, keys: ["outputTokens", "output_tokens"]) + let reasoningOutputTokens = firstInt(in: usage, keys: [ + "reasoningOutputTokens", "reasoning_output_tokens" + ]) + let total = outputTokens + reasoningOutputTokens + return total > 0 ? total : nil + } + + static func tokenUsageTokensUsed(from params: [String: JSONValue]) -> Int? { + let usage = tokenUsageSummary(from: params) + let tokensUsed = firstInt(in: usage, keys: [ + "tokensUsed", "tokens_used", "totalTokens", "total_tokens", + "total", "used", "currentTokens", "current_tokens" + ]) + return tokensUsed > 0 ? tokensUsed : nil + } + + static func tokenUsageTokenBudget(from params: [String: JSONValue]) -> Int? { + let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) + ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]) + ?? params + let tokenBudget = firstInt(in: usage, keys: [ + "tokenBudget", "token_budget", "modelContextWindow", "model_context_window" + ]) + return tokenBudget > 0 ? tokenBudget : nil + } + + static func tokenUsageSummary(from params: [String: JSONValue]) -> [String: JSONValue] { + let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) + ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]) + ?? params + return firstObject(in: usage, keys: ["last"]) + ?? firstObject(in: usage, keys: ["total"]) + ?? usage + } + + static func firstObject(in object: [String: JSONValue], keys: [String]) -> [String: JSONValue]? { + for key in keys { + if let value = object[key]?.objectValue { return value } + } + return nil + } + + static func firstNestedObject(in value: JSONValue, keys: [String]) -> [String: JSONValue]? { + if let object = value.objectValue { + if let direct = firstObject(in: object, keys: keys) { return direct } + for nested in object.values { + if let found = firstNestedObject(in: nested, keys: keys) { return found } + } + } + if let array = value.arrayValue { + for nested in array { + if let found = firstNestedObject(in: nested, keys: keys) { return found } + } + } + return nil + } + + static func firstInt(in object: [String: JSONValue], keys: [String]) -> Int { + for key in keys { + if let value = object[key]?.numberValue { return Int(value) } + if let value = object[key]?.stringValue, let intValue = Int(value) { return intValue } + } + return 0 + } + + static func nestedInputCacheReadTokens(in usage: [String: JSONValue]) -> Int { + guard let details = firstObject(in: usage, keys: [ + "input_tokens_details", "inputTokensDetails", "prompt_tokens_details", "promptTokensDetails" + ]) else { return 0 } + return firstInt(in: details, keys: [ + "cached_tokens", "cachedTokens", "cache_read_input_tokens", "cacheReadInputTokens" + ]) + } + + static func usageMessageId(from params: [String: JSONValue], fallback: String?) -> String { + firstString(in: params, keys: [ + "messageId", "message_id", "responseId", "response_id", + "turnId", "turn_id", "itemId", "item_id", "id" + ]) ?? fallback ?? "codex-turn" + } + + static func claudeTextDelta(_ text: String) -> String { + jsonString([ + "type": "content_block_delta", + "delta": ["type": "text_delta", "text": text] + ]) + } + + static func claudeThinkingDelta(_ text: String) -> String { + jsonString([ + "type": "content_block_delta", + "delta": ["type": "thinking_delta", "thinking": text] + ]) + } + + static func claudeToolStart(id: String, name: String) -> String { + jsonString([ + "type": "content_block_start", + "content_block": ["type": "tool_use", "id": id, "name": name] + ]) + } + + static func claudeInputDelta(_ input: [String: JSONValue]) -> String { + let data = (try? JSONEncoder().encode(JSONValue.object(input))) ?? Data("{}".utf8) + let inputText = String(data: data, encoding: .utf8) ?? "{}" + return jsonString([ + "type": "content_block_delta", + "delta": ["type": "input_json_delta", "partial_json": inputText] + ]) + } + + static func claudeContentBlockStop() -> String { + jsonString(["type": "content_block_stop"]) + } + + static func jsonString(_ object: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: object), + let text = String(data: data, encoding: .utf8) else { return "{}" } + return text + } +} diff --git a/RxCode/Services/CodexAppServer+Process.swift b/RxCode/Services/CodexAppServer+Process.swift new file mode 100644 index 0000000..532a271 --- /dev/null +++ b/RxCode/Services/CodexAppServer+Process.swift @@ -0,0 +1,148 @@ +import Foundation +import RxCodeCore +import os + +extension CodexAppServer { + func fetchRateLimitsUncached() async -> RateLimitUsage? { + guard let binary = await findCodexBinary() else { + logger.warning("Skipping Codex rate-limit fetch because no codex binary was found") + return nil + } + do { + let streamId = UUID() + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: nil) + defer { finalize(streamId: streamId) } + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "account/rateLimits/read", params: .null), to: handles.stdin) + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let requestId = Self.idString(object["id"]), object["method"] != nil { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + if Self.idString(object["id"]) == "2", let result = object["result"] { + let usage = Self.parseCodexRateLimits(from: result) + if let usage { + logger.info("Codex rate limits 5h=\(usage.fiveHourPercent)% 24h=\(usage.twentyFourHourPercent ?? 0)%") + } else { + logger.warning("Codex account/rateLimits/read returned no parseable limits") + } + return usage + } + + if object["method"]?.stringValue == "account/rateLimits/updated", + let params = object["params"] { + return Self.parseCodexRateLimits(from: params) + } + } + } catch { + logger.warning("Codex rate-limit fetch failed: \(error.localizedDescription)") + } + return nil + } + + func fetchModelsFromDebugCommand(binary: String) async -> [AgentModel] { + do { + let output = try await runShellCommand(binary, arguments: ["debug", "models"]) + guard let value = Self.decodeJSONValue(output) else { + logger.warning("Could not decode codex debug models output as JSON") + return [] + } + return Self.parseModels(from: value) + } catch { + logger.warning("Codex debug models failed: \(error.localizedDescription)") + return [] + } + } + + func spawnAppServer(binary: String, streamId: UUID, cwd: String?, configOverrides: [String] = []) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) { + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = ["app-server", "--listen", "stdio://"] + configOverrides + if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } + process.environment = await resolvedEnvironment() + + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + throw CodexError.spawnFailed(error.localizedDescription) + } + + let stdinHandle = stdin.fileHandleForWriting + running[streamId] = RunningProcess(process: process, stdin: stdinHandle) + readStderr(stderr, streamId: streamId) + return (process, stdinHandle, stdout) + } + + func readStderr(_ stderr: Pipe, streamId: UUID) { + Task.detached { [weak self] in + let data = stderr.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + await self?.appendStderr(text, streamId: streamId) + } + } + + func appendStderr(_ text: String, streamId: UUID) { + stderrBuffers[streamId, default: ""] += text + } + + func findNvmCodexBinary(root: String) -> String? { + let fm = FileManager.default + guard let versions = try? fm.contentsOfDirectory(atPath: root) else { return nil } + for version in versions.sorted(by: >) { + let candidate = "\(root)/\(version)/bin/codex" + let resolved = (candidate as NSString).resolvingSymlinksInPath + if fm.fileExists(atPath: resolved) && fm.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + func resolvedEnvironment() async -> [String: String] { + var env = ProcessInfo.processInfo.environment + if let cachedShellPath { + env["PATH"] = cachedShellPath + return env + } + let rawShellPath = try? await runShellCommand("/bin/zsh", arguments: ["-ilc", "print -rn -- $PATH"], injectPath: false) + let shellPath = rawShellPath?.trimmingCharacters(in: .whitespacesAndNewlines) + if let shellPath, !shellPath.isEmpty { + cachedShellPath = shellPath + env["PATH"] = shellPath + } + return env + } + + func runShellCommand(_ executable: String, arguments: [String], injectPath: Bool = true) async throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if injectPath { + process.environment = await resolvedEnvironment() + } + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + let out = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if process.terminationStatus != 0 { + let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw CodexError.versionCheckFailed(err.isEmpty ? out : err) + } + return out + } +} diff --git a/RxCode/Services/CodexAppServer+Protocol.swift b/RxCode/Services/CodexAppServer+Protocol.swift new file mode 100644 index 0000000..0431b16 --- /dev/null +++ b/RxCode/Services/CodexAppServer+Protocol.swift @@ -0,0 +1,174 @@ +import Foundation +import RxCodeCore +import os + +extension CodexAppServer { + func initializeParams() -> [String: JSONValue] { + [ + "clientInfo": .object([ + "name": .string("RxCode"), + "title": .string("RxCode"), + "version": .string(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0") + ]), + "capabilities": .object([:]) + ] + } + + func threadParams(threadId: String?, cwd: String) -> [String: JSONValue] { + var params: [String: JSONValue] = ["cwd": .string(cwd)] + if let threadId { params["threadId"] = .string(threadId) } + return params + } + + func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool, ideInstructions: String?) -> [String: JSONValue] { + var params: [String: JSONValue] = ["cwd": .string(cwd)] + if let threadId { params["threadId"] = .string(threadId) } + params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) + params["sandbox"] = .string(Self.codexSandboxMode(permissionMode: permissionMode, planMode: planMode)) + var instructions: [String] = [] + if let ideInstructions, !ideInstructions.isEmpty { instructions.append(ideInstructions) } + if planMode { instructions.append(Self.planModeInstructions) } + if !instructions.isEmpty { + params["developerInstructions"] = .string(instructions.joined(separator: "\n\n")) + } + return params + } + + func turnParams(threadId: String, prompt: String, cwd: String, model: String?, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { + var params: [String: JSONValue] = [ + "threadId": .string(threadId), + "cwd": .string(cwd), + "input": .array([ + .object(["type": .string("text"), "text": .string(prompt)]) + ]) + ] + if let model { params["model"] = .string(model) } + params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) + params["sandboxPolicy"] = Self.codexSandboxPolicy(permissionMode: permissionMode, planMode: planMode) + return params + } + + static let planModeInstructions = """ + Plan mode is enabled. Produce a clear, step-by-step plan using the update_plan tool. \ + Do not modify files; do not run commands that mutate state. \ + Read-only inspection is allowed. End with a concise summary of the proposed plan and \ + wait for the user to disable plan mode before making changes. + """ + + /// Developer-role instructions handed to Codex on `thread/start`. Tells the + /// agent about the IDE-provided `rxcode-ide` MCP server — cross-project + /// chat, thread history, running jobs, usage, and durable cross-session + /// memory. Only emitted when the IDE MCP bridge is wired into the turn. + static let ideToolsDeveloperInstructions = """ + # RxCode IDE tools + + You are running inside RxCode, a desktop IDE that hosts multiple projects, \ + each with its own chat threads and agents. RxCode wires in a local MCP \ + server named `rxcode-ide`; its tools appear in your available tools list. \ + Use them to coordinate with other agents and to read editor state: + + - `ide__get_projects` — list every project registered in RxCode, so you \ + can discover sibling projects to read or message. + - `ide__get_threads` — list or natural-language search chat threads across \ + projects. + - `ide__get_thread_messages` — fetch the message history of a specific \ + thread by id. + - `ide__send_to_thread` — talk to another project's agent: send a prompt \ + to an existing thread or start a new thread in a project. This triggers a \ + real agent run that may consume tokens; it returns the other agent's reply. + - `ide__get_running_jobs` / `ide__get_job_output` — inspect run-profile \ + jobs executing in the IDE. + - `ide__get_usage` — current rate-limit / token usage. + + Reach for these when a task spans projects rather than guessing. Prefer \ + reading threads first; only use `ide__send_to_thread` when you actually \ + need another agent to act, since it costs tokens. + + RxCode also persists durable memories — stable user preferences, project \ + facts, and decisions — across sessions. Use these tools to recall and \ + store them: + + - `ide__memory_search` — before starting a task, search for saved \ + preferences, facts, or decisions relevant to it instead of asking the \ + user to repeat themselves. + - `ide__memory_add` — when the user states a stable preference, project \ + fact, or decision ("remember…", "from now on…", "always…"), save it. Set \ + `kind` (`preference`/`fact`/`decision`) and `scope` (`global` for \ + cross-project, `project` for repo-specific). + - `ide__memory_update` — when saved information changes, update the \ + existing entry by `id` rather than adding a duplicate. + - `ide__memory_delete` — remove a memory by `id` when it is no longer valid. + + Only store stable, reusable information in memory — not transient task \ + details. + """ + + /// True when the `rxcode-ide` MCP bridge is present in the `-c` overrides. + static func includesIDEServer(_ overrides: [String]) -> Bool { + overrides.contains { $0.hasPrefix("mcp_servers.rxcode-ide=") } + } + + static func codexApprovalPolicy(permissionMode: PermissionMode, planMode: Bool) -> String { + if planMode { return "on-request" } + switch permissionMode { + case .default, .plan: return "untrusted" + case .acceptEdits, .auto: return "on-request" + case .bypassPermissions: return "never" + } + } + + static func codexSandboxMode(permissionMode: PermissionMode, planMode: Bool) -> String { + if planMode { return "read-only" } + switch permissionMode { + case .bypassPermissions: return "danger-full-access" + default: return "workspace-write" + } + } + + static func codexSandboxPolicy(permissionMode: PermissionMode, planMode: Bool) -> JSONValue { + if planMode { + return .object(["type": .string("readOnly"), "networkAccess": .bool(false)]) + } + switch permissionMode { + case .bypassPermissions: + return .object(["type": .string("dangerFullAccess")]) + default: + return .object(["type": .string("workspaceWrite")]) + } + } + + static func request(id: Int, method: String, params: [String: JSONValue]) -> JSONValue { + request(id: id, method: method, params: .object(params)) + } + + static func request(id: Int, method: String, params: JSONValue) -> JSONValue { + .object(["jsonrpc": .string("2.0"), "id": .number(Double(id)), "method": .string(method), "params": params]) + } + + static func response(id: String, result: [String: JSONValue]) -> JSONValue { + let idValue = Double(id).map(JSONValue.number) ?? .string(id) + return .object(["jsonrpc": .string("2.0"), "id": idValue, "result": .object(result)]) + } + + static func notification(method: String, params: [String: JSONValue]) -> JSONValue { + .object(["jsonrpc": .string("2.0"), "method": .string(method), "params": .object(params)]) + } + + static func writeJSONLine(_ value: JSONValue, to handle: FileHandle) throws { + let data = try JSONEncoder().encode(value) + try handle.write(contentsOf: data) + try handle.write(contentsOf: Data([0x0A])) + } + + static func decodeObject(_ line: String) -> [String: JSONValue]? { + guard let data = line.data(using: .utf8), + let value = try? JSONDecoder().decode(JSONValue.self, from: data) else { return nil } + return value.objectValue + } + + static func decodeJSONValue(_ text: String) -> JSONValue? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(JSONValue.self, from: data) + } +} diff --git a/RxCode/Services/CodexAppServer+Summaries.swift b/RxCode/Services/CodexAppServer+Summaries.swift new file mode 100644 index 0000000..6fafe09 --- /dev/null +++ b/RxCode/Services/CodexAppServer+Summaries.swift @@ -0,0 +1,280 @@ +import Foundation +import RxCodeCore +import os + +extension CodexAppServer { + func generateSessionTitle(firstUserMessage: String, model: String?) async -> String? { + guard let binary = await findCodexBinary() else { return nil } + let trimmedUser = String(firstUserMessage.prefix(500)) + let prompt = """ + Summarize the following user message as a 3-6 word chat title. Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. + + \(trimmedUser) + """ + + let streamId = UUID() + var title = "" + + do { + let cwd = FileManager.default.homeDirectoryForCurrentUser.path + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + defer { finalize(streamId: streamId) } + + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId: String? + var turnStarted = false + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? UUID().uuidString + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) + turnStarted = true + } + default: + break + } + continue + } + + guard let method = object["method"]?.stringValue else { continue } + if let requestId = Self.idString(object["id"]) { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/agentMessage/delta", "item/agent_message/delta": + title += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" + case "turn/completed": + return cleanTitle(title) + case "turn/failed", "error": + return nil + default: + break + } + } + } catch { + logger.warning("Codex title generation failed: \(error.localizedDescription)") + } + return cleanTitle(title) + } + + func generateResponseNotificationSummary(responseText: String, model: String?) async -> String? { + guard let binary = await findCodexBinary() else { return nil } + let trimmedResponse = String(responseText.prefix(4000)) + let prompt = """ + Summarize the following assistant response for a macOS notification. Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. No markdown. + + \(trimmedResponse) + """ + + let streamId = UUID() + var summary = "" + + do { + let cwd = FileManager.default.homeDirectoryForCurrentUser.path + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + defer { finalize(streamId: streamId) } + + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId: String? + var turnStarted = false + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? UUID().uuidString + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) + turnStarted = true + } + default: + break + } + continue + } + + guard let method = object["method"]?.stringValue else { continue } + if let requestId = Self.idString(object["id"]) { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/agentMessage/delta", "item/agent_message/delta": + summary += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" + case "turn/completed": + return cleanNotificationSummary(summary) + case "turn/failed", "error": + return nil + default: + break + } + } + } catch { + logger.warning("Codex notification summary generation failed: \(error.localizedDescription)") + } + return cleanNotificationSummary(summary) + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String, + model: String? + ) async -> String? { + let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = """ + Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. + Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. + Reply with only the updated summary. + + Previous summary: + \((previous?.isEmpty == false) ? previous! : "None") + + Latest user request: + \(String(userMessage.prefix(2000))) + + Final assistant response: + \(String(finalResponse.prefix(4000))) + """ + guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } + return cleanSummary(raw, limit: 1800) + } + + func generateMemoryOperations( + existingMemories: [(id: String, content: String)], + userMessage: String, + finalResponse: String, + model: String? + ) async -> String? { + let prompt = OpenAISummarizationService.memoryExtractionPrompt( + existingMemories: existingMemories, + userMessage: userMessage, + finalResponse: finalResponse + ) + guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } + return cleanSummary(raw, limit: 3000) + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)], + model: String? + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) + guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } + return cleanSummary(raw, limit: 1800) + } + + func generateCodexPlainSummary(prompt: String, model: String?) async -> String? { + guard let binary = await findCodexBinary() else { return nil } + let streamId = UUID() + var summary = "" + + do { + let cwd = FileManager.default.homeDirectoryForCurrentUser.path + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + defer { finalize(streamId: streamId) } + + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + var activeThreadId: String? + var turnStarted = false + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? UUID().uuidString + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) + turnStarted = true + } + default: + break + } + continue + } + + guard let method = object["method"]?.stringValue else { continue } + if let requestId = Self.idString(object["id"]) { + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) + continue + } + + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/agentMessage/delta", "item/agent_message/delta": + summary += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" + case "turn/completed": + return summary + case "turn/failed", "error": + return nil + default: + break + } + } + } catch { + logger.warning("Codex summary generation failed: \(error.localizedDescription)") + } + return summary + } + + func cleanTitle(_ raw: String) -> String? { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(80)) + } + + func cleanNotificationSummary(_ raw: String) -> String? { + cleanSummary(raw, limit: 180) + } + + func cleanSummary(_ raw: String, limit: Int) -> String? { + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + + let lower = cleaned.lowercased() + let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] + guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } + return String(cleaned.prefix(limit)) + } +} diff --git a/RxCode/Services/CodexAppServer+Turn.swift b/RxCode/Services/CodexAppServer+Turn.swift new file mode 100644 index 0000000..b6f5af6 --- /dev/null +++ b/RxCode/Services/CodexAppServer+Turn.swift @@ -0,0 +1,328 @@ +import Foundation +import RxCodeCore +import os + +extension CodexAppServer { + func runTurn( + streamId: UUID, + prompt: String, + cwd: String, + threadId: String?, + model: String?, + permissionMode: PermissionMode, + planMode: Bool, + mcpConfigOverrides: [String], + permissionServer: PermissionServer, + continuation: AsyncStream.Continuation + ) async { + do { + guard let binary = await findCodexBinary() else { throw CodexError.binaryNotFound } + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd, configOverrides: mcpConfigOverrides) + try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) + + // Surface the IDE tools blurb as developer instructions only when + // the `rxcode-ide` MCP bridge is wired into this turn's overrides. + let ideInstructions = Self.includesIDEServer(mcpConfigOverrides) ? Self.ideToolsDeveloperInstructions : nil + var activeThreadId = threadId + var turnStarted = false + var turnCompleted = false + var finalUsage: UsageInfo? + let startedAt = Date() + // Captured per turn so we can synthesize an `ExitPlanMode` tool call when a + // plan-mode turn completes. Codex never emits ExitPlanMode itself — its plan + // arrives as `turn/plan/updated` notifications (steps) and a final agent + // message (concise summary). See PlanCardView for the rendering contract. + var planItems: [TodoItem] = [] + var assistantTextBuffer = "" + + for try await line in handles.stdout.fileHandleForReading.bytes.lines { + guard !Task.isCancelled else { break } + guard let object = Self.decodeObject(line) else { continue } + + if let id = Self.idString(object["id"]), object["method"] == nil { + switch id { + case "1": + try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) + let method = activeThreadId == nil ? "thread/start" : "thread/resume" + let params = method == "thread/start" + ? threadStartParams(threadId: activeThreadId, cwd: cwd, permissionMode: permissionMode, planMode: planMode, ideInstructions: ideInstructions) + : threadParams(threadId: activeThreadId, cwd: cwd) + try Self.writeJSONLine(Self.request(id: 2, method: method, params: params), to: handles.stdin) + case "2": + if let result = object["result"] { + activeThreadId = Self.threadId(from: result) ?? activeThreadId ?? UUID().uuidString + continuation.yield(.system(SystemEvent( + subtype: "init", + sessionId: activeThreadId, + tools: nil, + model: model, + claudeCodeVersion: nil + ))) + } + if let activeThreadId, !turnStarted { + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: permissionMode, planMode: planMode)), to: handles.stdin) + turnStarted = true + } + case "3": + break + default: + break + } + continue + } + + if let method = object["method"]?.stringValue { + if let requestId = Self.idString(object["id"]) { + try await handleServerRequest( + requestId: requestId, + method: method, + object: object, + activeThreadId: activeThreadId, + permissionMode: permissionMode, + planMode: planMode, + permissionServer: permissionServer, + stdin: handles.stdin + ) + } else { + let params = object["params"]?.objectValue ?? [:] + switch method { + case "turn/plan/updated": + if let items = TodoExtractor.parseCodexPlanUpdate(params: params) { + planItems = items + } + case "item/agentMessage/delta", "item/agent_message/delta": + if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { + assistantTextBuffer += text + } + default: + break + } + handleNotification(method: method, object: object, activeThreadId: activeThreadId, continuation: continuation) + if method == "turn/completed" || method == "turn/failed" { + finalUsage = Self.usageInfo(from: object) ?? finalUsage + turnCompleted = method == "turn/completed" + if turnCompleted, planMode { + emitSynthesizedExitPlanMode( + planItems: planItems, + assistantText: assistantTextBuffer, + continuation: continuation + ) + } + break + } + } + } + } + + let sid = activeThreadId ?? threadId ?? UUID().uuidString + let duration = Date().timeIntervalSince(startedAt) * 1000 + continuation.yield(.result(ResultEvent( + durationMs: duration, + totalCostUsd: nil, + sessionId: sid, + isError: !turnCompleted, + totalTurns: 1, + usage: finalUsage, + contextWindow: nil + ))) + } catch { + stderrBuffers[streamId, default: ""] += "\n\(error.localizedDescription)" + let sid = threadId ?? "codex-\(streamId.uuidString)" + continuation.yield(.result(ResultEvent( + durationMs: nil, + totalCostUsd: nil, + sessionId: sid, + isError: true, + totalTurns: nil, + usage: nil, + contextWindow: nil + ))) + } + finalize(streamId: streamId) + continuation.finish() + } + + func handleNotification( + method: String, + object: [String: JSONValue], + activeThreadId: String?, + continuation: AsyncStream.Continuation + ) { + let params = object["params"]?.objectValue ?? [:] + notificationMethodCounts[method, default: 0] += 1 + if notificationMethodCounts[method] == 1 { + logger.info("[CodexAppServer] notification method=\(method, privacy: .public)") + } + + let liveUsage = method == "thread/tokenUsage/updated" + ? Self.liveTokenUsage(from: params) + : nil + if method == "thread/tokenUsage/updated" { + let outputTokens = Self.tokenUsageOutputTokens(from: params) ?? 0 + let tokenUsage = Self.tokenUsageTokensUsed(from: params) ?? 0 + let tokenBudget = Self.tokenUsageTokenBudget(from: params) ?? 0 + logger.info("[CodexAppServer] tokenUsage.updated outputTokens=\(outputTokens) tokensUsed=\(tokenUsage) tokenBudget=\(tokenBudget) parsedOutput=\(liveUsage?.outputTokens ?? 0)") + if liveUsage?.outputTokens ?? 0 == 0 { + logger.info("[CodexAppServer] tokenUsage.payload \(JSONValue.object(params).description, privacy: .public)") + } + } + if let usage = liveUsage ?? Self.usageInfo(from: object) { + continuation.yield(.assistant(AssistantMessage( + id: Self.usageMessageId(from: params, fallback: activeThreadId), + role: "assistant", + content: [], + usage: usage + ))) + } + switch method { + case "thread/started": + let sid = Self.threadId(from: .object(params)) ?? activeThreadId + continuation.yield(.system(SystemEvent(subtype: "init", sessionId: sid, tools: nil, model: nil, claudeCodeVersion: nil))) + case "item/agentMessage/delta", "item/agent_message/delta": + if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { + continuation.yield(.unknown(Self.claudeTextDelta(text))) + } + case "item/reasoning/textDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/summaryPartAdded": + let text = Self.firstString(in: params, keys: ["delta", "text", "content", "summary"]) ?? "" + continuation.yield(.unknown(Self.claudeThinkingDelta(text))) + case "item/started": + if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { + emitToolStart(item: item, continuation: continuation) + } + case "turn/plan/updated": + if let items = TodoExtractor.parseCodexPlanUpdate(params: params) { + let sessionId = Self.firstString(in: params, keys: ["threadId", "thread_id"]) ?? activeThreadId + continuation.yield(.todoSnapshot(TodoSnapshotEvent(sessionId: sessionId, items: items))) + } + case "item/completed": + if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { + emitToolCompletion(item: item, continuation: continuation) + } + case "error", "turn/failed": + if let message = Self.firstString(in: params, keys: ["message", "error"]) { + continuation.yield(.unknown(Self.claudeTextDelta(message))) + } + default: + break + } + } + + func emitToolStart(item: [String: JSONValue], continuation: AsyncStream.Continuation) { + guard let name = toolName(from: item), name != "message" else { return } + let id = Self.firstString(in: item, keys: ["id", "itemId", "callId"]) ?? UUID().uuidString + let input = item["input"]?.objectValue ?? item["arguments"]?.objectValue ?? item + continuation.yield(.unknown(Self.claudeToolStart(id: id, name: name))) + continuation.yield(.unknown(Self.claudeInputDelta(input))) + continuation.yield(.unknown(Self.claudeContentBlockStop())) + } + + func emitToolCompletion(item: [String: JSONValue], continuation: AsyncStream.Continuation) { + guard let name = toolName(from: item), name != "message" else { return } + let id = Self.firstString(in: item, keys: ["id", "itemId", "callId"]) ?? UUID().uuidString + let output = Self.firstString(in: item, keys: ["output", "result", "summary", "message"]) ?? "" + let isError = item["error"] != nil || item["isError"]?.boolValue == true + continuation.yield(.user(UserMessage(toolUseId: id, content: output, isError: isError))) + } + + /// Synthesize a Claude-shaped `ExitPlanMode` tool call so `PlanCardView` can render + /// an interactive accept/reject card at the end of a Codex plan-mode turn. Plan body + /// is rendered from the latest `update_plan` steps; falls back to the assistant's + /// final summary text if no plan steps were emitted. + func emitSynthesizedExitPlanMode( + planItems: [TodoItem], + assistantText: String, + continuation: AsyncStream.Continuation + ) { + let stepsMarkdown = Self.planItemsMarkdown(planItems) + let trimmedText = assistantText.trimmingCharacters(in: .whitespacesAndNewlines) + let markdown: String + if !stepsMarkdown.isEmpty { + markdown = stepsMarkdown + } else if !trimmedText.isEmpty { + markdown = trimmedText + } else { + return + } + let id = "codex-plan-\(UUID().uuidString)" + continuation.yield(.unknown(Self.claudeToolStart(id: id, name: "ExitPlanMode"))) + continuation.yield(.unknown(Self.claudeInputDelta(["plan": .string(markdown)]))) + continuation.yield(.unknown(Self.claudeContentBlockStop())) + } + + static func planItemsMarkdown(_ items: [TodoItem]) -> String { + guard !items.isEmpty else { return "" } + return items.enumerated().map { index, item in + let suffix: String + switch item.status { + case .completed: suffix = " *(completed)*" + case .inProgress: suffix = " *(in progress)*" + case .pending: suffix = "" + } + return "\(index + 1). \(item.content)\(suffix)" + }.joined(separator: "\n") + } + + func handleServerRequest( + requestId: String, + method: String, + object: [String: JSONValue], + activeThreadId: String?, + permissionMode: PermissionMode, + planMode: Bool, + permissionServer: PermissionServer, + stdin: FileHandle + ) async throws { + let params = object["params"]?.objectValue ?? [:] + switch method { + case "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "request/approval": + // Belt-and-suspenders: even though we set approvalPolicy on thread/start, + // older codex versions may still escalate. Auto-accept when the user picked + // .auto or .bypassPermissions and we're not in plan mode. + if !planMode, permissionMode == .auto || permissionMode == .bypassPermissions { + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "decision": .string("accept") + ]), to: stdin) + return + } + let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId + let command = Self.firstString(in: params, keys: ["command", "cmd"]) + let toolName = command == nil ? "Edit" : "Bash" + var input = params + if let command { input["command"] = .string(command) } + let decision = await permissionServer.requestDecision( + toolUseId: toolUseId, + sessionId: activeThreadId, + toolName: toolName, + toolInput: input, + mode: permissionMode + ) + let approved = decision == .allow || { + if case .allowAlwaysCommand = decision { return true } + if case .allowSessionTool = decision { return true } + if case .allowAndSetMode = decision { return true } + return false + }() + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "decision": .string(approved ? "accept" : "reject") + ]), to: stdin) + case "userInput/request": + let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId + let decision = await permissionServer.requestDecision( + toolUseId: toolUseId, + sessionId: activeThreadId, + toolName: "AskUserQuestion", + toolInput: params, + mode: permissionMode + ) + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "decision": .string(decision == .allow ? "accept" : "reject") + ]), to: stdin) + default: + try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: stdin) + } + } +} diff --git a/RxCode/Services/CodexAppServer.swift b/RxCode/Services/CodexAppServer.swift index 3dbb6ae..e55aa8d 100644 --- a/RxCode/Services/CodexAppServer.swift +++ b/RxCode/Services/CodexAppServer.swift @@ -20,33 +20,39 @@ actor CodexAppServer { } } - private struct RunningProcess { + struct RunningProcess { let process: Process let stdin: FileHandle } - private let logger = Logger( + struct CodexRateLimitWindow { + let percent: Double + let resetsAt: Date? + let durationMinutes: Int? + } + + let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.claudework", category: "CodexAppServer" ) - private var running: [UUID: RunningProcess] = [:] - private var stderrBuffers: [UUID: String] = [:] - private var cachedShellPath: String? - private var cachedRateLimits: RateLimitUsage? - private var cachedRateLimitsAt: Date? - private var rateLimitsFetchTask: Task? - private let rateLimitsCacheTTL: TimeInterval = 300 - private var notificationMethodCounts: [String: Int] = [:] + var running: [UUID: RunningProcess] = [:] + var stderrBuffers: [UUID: String] = [:] + var cachedShellPath: String? + var cachedRateLimits: RateLimitUsage? + var cachedRateLimitsAt: Date? + var rateLimitsFetchTask: Task? + let rateLimitsCacheTTL: TimeInterval = 300 + var notificationMethodCounts: [String: Int] = [:] /// Reference to the permission server for in-band permission requests. /// Wired once at app init; the `AgentBackend.send(_:)` adapter pulls it /// from here so callers don't have to pass it on every turn. - private weak var permissionServer: PermissionServer? + weak var permissionServer: PermissionServer? func setPermissionServer(_ server: PermissionServer) { self.permissionServer = server } - private static var candidatePaths: [String] { + static var candidatePaths: [String] { let home = FileManager.default.homeDirectoryForCurrentUser.path return [ "/opt/homebrew/bin/codex", @@ -152,309 +158,6 @@ actor CodexAppServer { return cachedRateLimits } - func generateSessionTitle(firstUserMessage: String, model: String?) async -> String? { - guard let binary = await findCodexBinary() else { return nil } - let trimmedUser = String(firstUserMessage.prefix(500)) - let prompt = """ - Summarize the following user message as a 3-6 word chat title. Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. - - \(trimmedUser) - """ - - let streamId = UUID() - var title = "" - - do { - let cwd = FileManager.default.homeDirectoryForCurrentUser.path - let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) - defer { finalize(streamId: streamId) } - - try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) - - var activeThreadId: String? - var turnStarted = false - - for try await line in handles.stdout.fileHandleForReading.bytes.lines { - guard let object = Self.decodeObject(line) else { continue } - - if let id = Self.idString(object["id"]), object["method"] == nil { - switch id { - case "1": - try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) - try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) - case "2": - if let result = object["result"] { - activeThreadId = Self.threadId(from: result) ?? UUID().uuidString - } - if let activeThreadId, !turnStarted { - try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) - turnStarted = true - } - default: - break - } - continue - } - - guard let method = object["method"]?.stringValue else { continue } - if let requestId = Self.idString(object["id"]) { - try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) - continue - } - - let params = object["params"]?.objectValue ?? [:] - switch method { - case "item/agentMessage/delta", "item/agent_message/delta": - title += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" - case "turn/completed": - return cleanTitle(title) - case "turn/failed", "error": - return nil - default: - break - } - } - } catch { - logger.warning("Codex title generation failed: \(error.localizedDescription)") - } - return cleanTitle(title) - } - - func generateResponseNotificationSummary(responseText: String, model: String?) async -> String? { - guard let binary = await findCodexBinary() else { return nil } - let trimmedResponse = String(responseText.prefix(4000)) - let prompt = """ - Summarize the following assistant response for a macOS notification. Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. No markdown. - - \(trimmedResponse) - """ - - let streamId = UUID() - var summary = "" - - do { - let cwd = FileManager.default.homeDirectoryForCurrentUser.path - let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) - defer { finalize(streamId: streamId) } - - try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) - - var activeThreadId: String? - var turnStarted = false - - for try await line in handles.stdout.fileHandleForReading.bytes.lines { - guard let object = Self.decodeObject(line) else { continue } - - if let id = Self.idString(object["id"]), object["method"] == nil { - switch id { - case "1": - try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) - try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) - case "2": - if let result = object["result"] { - activeThreadId = Self.threadId(from: result) ?? UUID().uuidString - } - if let activeThreadId, !turnStarted { - try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) - turnStarted = true - } - default: - break - } - continue - } - - guard let method = object["method"]?.stringValue else { continue } - if let requestId = Self.idString(object["id"]) { - try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) - continue - } - - let params = object["params"]?.objectValue ?? [:] - switch method { - case "item/agentMessage/delta", "item/agent_message/delta": - summary += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" - case "turn/completed": - return cleanNotificationSummary(summary) - case "turn/failed", "error": - return nil - default: - break - } - } - } catch { - logger.warning("Codex notification summary generation failed: \(error.localizedDescription)") - } - return cleanNotificationSummary(summary) - } - - func generateThreadSummary( - previousSummary: String?, - userMessage: String, - finalResponse: String, - model: String? - ) async -> String? { - let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) - let prompt = """ - Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. - Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. - Reply with only the updated summary. - - Previous summary: - \((previous?.isEmpty == false) ? previous! : "None") - - Latest user request: - \(String(userMessage.prefix(2000))) - - Final assistant response: - \(String(finalResponse.prefix(4000))) - """ - guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } - return cleanSummary(raw, limit: 1800) - } - - func generateMemoryOperations( - existingMemories: [(id: String, content: String)], - userMessage: String, - finalResponse: String, - model: String? - ) async -> String? { - let prompt = OpenAISummarizationService.memoryExtractionPrompt( - existingMemories: existingMemories, - userMessage: userMessage, - finalResponse: finalResponse - ) - guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } - return cleanSummary(raw, limit: 3000) - } - - func generateBranchBriefing( - threadSummaries: [(title: String, summary: String)], - model: String? - ) async -> String? { - guard !threadSummaries.isEmpty else { return nil } - let prompt = OpenAISummarizationService.branchBriefingPrompt(threadSummaries: threadSummaries) - guard let raw = await generateCodexPlainSummary(prompt: prompt, model: model) else { return nil } - return cleanSummary(raw, limit: 1800) - } - - private func generateCodexPlainSummary(prompt: String, model: String?) async -> String? { - guard let binary = await findCodexBinary() else { return nil } - let streamId = UUID() - var summary = "" - - do { - let cwd = FileManager.default.homeDirectoryForCurrentUser.path - let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) - defer { finalize(streamId: streamId) } - - try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) - - var activeThreadId: String? - var turnStarted = false - - for try await line in handles.stdout.fileHandleForReading.bytes.lines { - guard let object = Self.decodeObject(line) else { continue } - - if let id = Self.idString(object["id"]), object["method"] == nil { - switch id { - case "1": - try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) - try Self.writeJSONLine(Self.request(id: 2, method: "thread/start", params: threadParams(threadId: nil, cwd: cwd)), to: handles.stdin) - case "2": - if let result = object["result"] { - activeThreadId = Self.threadId(from: result) ?? UUID().uuidString - } - if let activeThreadId, !turnStarted { - try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) - turnStarted = true - } - default: - break - } - continue - } - - guard let method = object["method"]?.stringValue else { continue } - if let requestId = Self.idString(object["id"]) { - try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) - continue - } - - let params = object["params"]?.objectValue ?? [:] - switch method { - case "item/agentMessage/delta", "item/agent_message/delta": - summary += Self.firstString(in: params, keys: ["delta", "text", "content"]) ?? "" - case "turn/completed": - return summary - case "turn/failed", "error": - return nil - default: - break - } - } - } catch { - logger.warning("Codex summary generation failed: \(error.localizedDescription)") - } - return summary - } - - private func fetchRateLimitsUncached() async -> RateLimitUsage? { - guard let binary = await findCodexBinary() else { - logger.warning("Skipping Codex rate-limit fetch because no codex binary was found") - return nil - } - do { - let streamId = UUID() - let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: nil) - defer { finalize(streamId: streamId) } - try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) - try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) - try Self.writeJSONLine(Self.request(id: 2, method: "account/rateLimits/read", params: .null), to: handles.stdin) - - for try await line in handles.stdout.fileHandleForReading.bytes.lines { - guard let object = Self.decodeObject(line) else { continue } - - if let requestId = Self.idString(object["id"]), object["method"] != nil { - try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: handles.stdin) - continue - } - - if Self.idString(object["id"]) == "2", let result = object["result"] { - let usage = Self.parseCodexRateLimits(from: result) - if let usage { - logger.info("Codex rate limits 5h=\(usage.fiveHourPercent)% 24h=\(usage.twentyFourHourPercent ?? 0)%") - } else { - logger.warning("Codex account/rateLimits/read returned no parseable limits") - } - return usage - } - - if object["method"]?.stringValue == "account/rateLimits/updated", - let params = object["params"] { - return Self.parseCodexRateLimits(from: params) - } - } - } catch { - logger.warning("Codex rate-limit fetch failed: \(error.localizedDescription)") - } - return nil - } - - private func fetchModelsFromDebugCommand(binary: String) async -> [AgentModel] { - do { - let output = try await runShellCommand(binary, arguments: ["debug", "models"]) - guard let value = Self.decodeJSONValue(output) else { - logger.warning("Could not decode codex debug models output as JSON") - return [] - } - return Self.parseModels(from: value) - } catch { - logger.warning("Codex debug models failed: \(error.localizedDescription)") - return [] - } - } - func send( streamId: UUID, prompt: String, @@ -509,945 +212,9 @@ actor CodexAppServer { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } - - private func runTurn( - streamId: UUID, - prompt: String, - cwd: String, - threadId: String?, - model: String?, - permissionMode: PermissionMode, - planMode: Bool, - mcpConfigOverrides: [String], - permissionServer: PermissionServer, - continuation: AsyncStream.Continuation - ) async { - do { - guard let binary = await findCodexBinary() else { throw CodexError.binaryNotFound } - let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd, configOverrides: mcpConfigOverrides) - try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) - - // Surface the IDE tools blurb as developer instructions only when - // the `rxcode-ide` MCP bridge is wired into this turn's overrides. - let ideInstructions = Self.includesIDEServer(mcpConfigOverrides) ? Self.ideToolsDeveloperInstructions : nil - var activeThreadId = threadId - var turnStarted = false - var turnCompleted = false - var finalUsage: UsageInfo? - let startedAt = Date() - // Captured per turn so we can synthesize an `ExitPlanMode` tool call when a - // plan-mode turn completes. Codex never emits ExitPlanMode itself — its plan - // arrives as `turn/plan/updated` notifications (steps) and a final agent - // message (concise summary). See PlanCardView for the rendering contract. - var planItems: [TodoItem] = [] - var assistantTextBuffer = "" - - for try await line in handles.stdout.fileHandleForReading.bytes.lines { - guard !Task.isCancelled else { break } - guard let object = Self.decodeObject(line) else { continue } - - if let id = Self.idString(object["id"]), object["method"] == nil { - switch id { - case "1": - try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) - let method = activeThreadId == nil ? "thread/start" : "thread/resume" - let params = method == "thread/start" - ? threadStartParams(threadId: activeThreadId, cwd: cwd, permissionMode: permissionMode, planMode: planMode, ideInstructions: ideInstructions) - : threadParams(threadId: activeThreadId, cwd: cwd) - try Self.writeJSONLine(Self.request(id: 2, method: method, params: params), to: handles.stdin) - case "2": - if let result = object["result"] { - activeThreadId = Self.threadId(from: result) ?? activeThreadId ?? UUID().uuidString - continuation.yield(.system(SystemEvent( - subtype: "init", - sessionId: activeThreadId, - tools: nil, - model: model, - claudeCodeVersion: nil - ))) - } - if let activeThreadId, !turnStarted { - try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: permissionMode, planMode: planMode)), to: handles.stdin) - turnStarted = true - } - case "3": - break - default: - break - } - continue - } - - if let method = object["method"]?.stringValue { - if let requestId = Self.idString(object["id"]) { - try await handleServerRequest( - requestId: requestId, - method: method, - object: object, - activeThreadId: activeThreadId, - permissionMode: permissionMode, - planMode: planMode, - permissionServer: permissionServer, - stdin: handles.stdin - ) - } else { - let params = object["params"]?.objectValue ?? [:] - switch method { - case "turn/plan/updated": - if let items = TodoExtractor.parseCodexPlanUpdate(params: params) { - planItems = items - } - case "item/agentMessage/delta", "item/agent_message/delta": - if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { - assistantTextBuffer += text - } - default: - break - } - handleNotification(method: method, object: object, activeThreadId: activeThreadId, continuation: continuation) - if method == "turn/completed" || method == "turn/failed" { - finalUsage = Self.usageInfo(from: object) ?? finalUsage - turnCompleted = method == "turn/completed" - if turnCompleted, planMode { - emitSynthesizedExitPlanMode( - planItems: planItems, - assistantText: assistantTextBuffer, - continuation: continuation - ) - } - break - } - } - } - } - - let sid = activeThreadId ?? threadId ?? UUID().uuidString - let duration = Date().timeIntervalSince(startedAt) * 1000 - continuation.yield(.result(ResultEvent( - durationMs: duration, - totalCostUsd: nil, - sessionId: sid, - isError: !turnCompleted, - totalTurns: 1, - usage: finalUsage, - contextWindow: nil - ))) - } catch { - stderrBuffers[streamId, default: ""] += "\n\(error.localizedDescription)" - let sid = threadId ?? "codex-\(streamId.uuidString)" - continuation.yield(.result(ResultEvent( - durationMs: nil, - totalCostUsd: nil, - sessionId: sid, - isError: true, - totalTurns: nil, - usage: nil, - contextWindow: nil - ))) - } - finalize(streamId: streamId) - continuation.finish() - } - - private func handleNotification( - method: String, - object: [String: JSONValue], - activeThreadId: String?, - continuation: AsyncStream.Continuation - ) { - let params = object["params"]?.objectValue ?? [:] - notificationMethodCounts[method, default: 0] += 1 - if notificationMethodCounts[method] == 1 { - logger.info("[CodexAppServer] notification method=\(method, privacy: .public)") - } - - let liveUsage = method == "thread/tokenUsage/updated" - ? Self.liveTokenUsage(from: params) - : nil - if method == "thread/tokenUsage/updated" { - let outputTokens = Self.tokenUsageOutputTokens(from: params) ?? 0 - let tokenUsage = Self.tokenUsageTokensUsed(from: params) ?? 0 - let tokenBudget = Self.tokenUsageTokenBudget(from: params) ?? 0 - logger.info("[CodexAppServer] tokenUsage.updated outputTokens=\(outputTokens) tokensUsed=\(tokenUsage) tokenBudget=\(tokenBudget) parsedOutput=\(liveUsage?.outputTokens ?? 0)") - if liveUsage?.outputTokens ?? 0 == 0 { - logger.info("[CodexAppServer] tokenUsage.payload \(JSONValue.object(params).description, privacy: .public)") - } - } - if let usage = liveUsage ?? Self.usageInfo(from: object) { - continuation.yield(.assistant(AssistantMessage( - id: Self.usageMessageId(from: params, fallback: activeThreadId), - role: "assistant", - content: [], - usage: usage - ))) - } - switch method { - case "thread/started": - let sid = Self.threadId(from: .object(params)) ?? activeThreadId - continuation.yield(.system(SystemEvent(subtype: "init", sessionId: sid, tools: nil, model: nil, claudeCodeVersion: nil))) - case "item/agentMessage/delta", "item/agent_message/delta": - if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { - continuation.yield(.unknown(Self.claudeTextDelta(text))) - } - case "item/reasoning/textDelta", - "item/reasoning/summaryTextDelta", - "item/reasoning/summaryPartAdded": - let text = Self.firstString(in: params, keys: ["delta", "text", "content", "summary"]) ?? "" - continuation.yield(.unknown(Self.claudeThinkingDelta(text))) - case "item/started": - if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { - emitToolStart(item: item, continuation: continuation) - } - case "turn/plan/updated": - if let items = TodoExtractor.parseCodexPlanUpdate(params: params) { - let sessionId = Self.firstString(in: params, keys: ["threadId", "thread_id"]) ?? activeThreadId - continuation.yield(.todoSnapshot(TodoSnapshotEvent(sessionId: sessionId, items: items))) - } - case "item/completed": - if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { - emitToolCompletion(item: item, continuation: continuation) - } - case "error", "turn/failed": - if let message = Self.firstString(in: params, keys: ["message", "error"]) { - continuation.yield(.unknown(Self.claudeTextDelta(message))) - } - default: - break - } - } - - private func emitToolStart(item: [String: JSONValue], continuation: AsyncStream.Continuation) { - guard let name = toolName(from: item), name != "message" else { return } - let id = Self.firstString(in: item, keys: ["id", "itemId", "callId"]) ?? UUID().uuidString - let input = item["input"]?.objectValue ?? item["arguments"]?.objectValue ?? item - continuation.yield(.unknown(Self.claudeToolStart(id: id, name: name))) - continuation.yield(.unknown(Self.claudeInputDelta(input))) - continuation.yield(.unknown(Self.claudeContentBlockStop())) - } - - private func emitToolCompletion(item: [String: JSONValue], continuation: AsyncStream.Continuation) { - guard let name = toolName(from: item), name != "message" else { return } - let id = Self.firstString(in: item, keys: ["id", "itemId", "callId"]) ?? UUID().uuidString - let output = Self.firstString(in: item, keys: ["output", "result", "summary", "message"]) ?? "" - let isError = item["error"] != nil || item["isError"]?.boolValue == true - continuation.yield(.user(UserMessage(toolUseId: id, content: output, isError: isError))) - } - - /// Synthesize a Claude-shaped `ExitPlanMode` tool call so `PlanCardView` can render - /// an interactive accept/reject card at the end of a Codex plan-mode turn. Plan body - /// is rendered from the latest `update_plan` steps; falls back to the assistant's - /// final summary text if no plan steps were emitted. - private func emitSynthesizedExitPlanMode( - planItems: [TodoItem], - assistantText: String, - continuation: AsyncStream.Continuation - ) { - let stepsMarkdown = Self.planItemsMarkdown(planItems) - let trimmedText = assistantText.trimmingCharacters(in: .whitespacesAndNewlines) - let markdown: String - if !stepsMarkdown.isEmpty { - markdown = stepsMarkdown - } else if !trimmedText.isEmpty { - markdown = trimmedText - } else { - return - } - let id = "codex-plan-\(UUID().uuidString)" - continuation.yield(.unknown(Self.claudeToolStart(id: id, name: "ExitPlanMode"))) - continuation.yield(.unknown(Self.claudeInputDelta(["plan": .string(markdown)]))) - continuation.yield(.unknown(Self.claudeContentBlockStop())) - } - - private static func planItemsMarkdown(_ items: [TodoItem]) -> String { - guard !items.isEmpty else { return "" } - return items.enumerated().map { index, item in - let suffix: String - switch item.status { - case .completed: suffix = " *(completed)*" - case .inProgress: suffix = " *(in progress)*" - case .pending: suffix = "" - } - return "\(index + 1). \(item.content)\(suffix)" - }.joined(separator: "\n") - } - - private func handleServerRequest( - requestId: String, - method: String, - object: [String: JSONValue], - activeThreadId: String?, - permissionMode: PermissionMode, - planMode: Bool, - permissionServer: PermissionServer, - stdin: FileHandle - ) async throws { - let params = object["params"]?.objectValue ?? [:] - switch method { - case "item/commandExecution/requestApproval", - "item/fileChange/requestApproval", - "request/approval": - // Belt-and-suspenders: even though we set approvalPolicy on thread/start, - // older codex versions may still escalate. Auto-accept when the user picked - // .auto or .bypassPermissions and we're not in plan mode. - if !planMode, permissionMode == .auto || permissionMode == .bypassPermissions { - try Self.writeJSONLine(Self.response(id: requestId, result: [ - "decision": .string("accept") - ]), to: stdin) - return - } - let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId - let command = Self.firstString(in: params, keys: ["command", "cmd"]) - let toolName = command == nil ? "Edit" : "Bash" - var input = params - if let command { input["command"] = .string(command) } - let decision = await permissionServer.requestDecision( - toolUseId: toolUseId, - sessionId: activeThreadId, - toolName: toolName, - toolInput: input, - mode: permissionMode - ) - let approved = decision == .allow || { - if case .allowAlwaysCommand = decision { return true } - if case .allowSessionTool = decision { return true } - if case .allowAndSetMode = decision { return true } - return false - }() - try Self.writeJSONLine(Self.response(id: requestId, result: [ - "decision": .string(approved ? "accept" : "reject") - ]), to: stdin) - case "userInput/request": - let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId - let decision = await permissionServer.requestDecision( - toolUseId: toolUseId, - sessionId: activeThreadId, - toolName: "AskUserQuestion", - toolInput: params, - mode: permissionMode - ) - try Self.writeJSONLine(Self.response(id: requestId, result: [ - "decision": .string(decision == .allow ? "accept" : "reject") - ]), to: stdin) - default: - try Self.writeJSONLine(Self.response(id: requestId, result: [:]), to: stdin) - } - } - - private func initializeParams() -> [String: JSONValue] { - [ - "clientInfo": .object([ - "name": .string("RxCode"), - "title": .string("RxCode"), - "version": .string(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0") - ]), - "capabilities": .object([:]) - ] - } - - private func threadParams(threadId: String?, cwd: String) -> [String: JSONValue] { - var params: [String: JSONValue] = ["cwd": .string(cwd)] - if let threadId { params["threadId"] = .string(threadId) } - return params - } - - private func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool, ideInstructions: String?) -> [String: JSONValue] { - var params: [String: JSONValue] = ["cwd": .string(cwd)] - if let threadId { params["threadId"] = .string(threadId) } - params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) - params["sandbox"] = .string(Self.codexSandboxMode(permissionMode: permissionMode, planMode: planMode)) - var instructions: [String] = [] - if let ideInstructions, !ideInstructions.isEmpty { instructions.append(ideInstructions) } - if planMode { instructions.append(Self.planModeInstructions) } - if !instructions.isEmpty { - params["developerInstructions"] = .string(instructions.joined(separator: "\n\n")) - } - return params - } - - private func turnParams(threadId: String, prompt: String, cwd: String, model: String?, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { - var params: [String: JSONValue] = [ - "threadId": .string(threadId), - "cwd": .string(cwd), - "input": .array([ - .object(["type": .string("text"), "text": .string(prompt)]) - ]) - ] - if let model { params["model"] = .string(model) } - params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) - params["sandboxPolicy"] = Self.codexSandboxPolicy(permissionMode: permissionMode, planMode: planMode) - return params - } - - private static let planModeInstructions = """ - Plan mode is enabled. Produce a clear, step-by-step plan using the update_plan tool. \ - Do not modify files; do not run commands that mutate state. \ - Read-only inspection is allowed. End with a concise summary of the proposed plan and \ - wait for the user to disable plan mode before making changes. - """ - - /// Developer-role instructions handed to Codex on `thread/start`. Tells the - /// agent about the IDE-provided `rxcode-ide` MCP server — cross-project - /// chat, thread history, running jobs, usage, and durable cross-session - /// memory. Only emitted when the IDE MCP bridge is wired into the turn. - private static let ideToolsDeveloperInstructions = """ - # RxCode IDE tools - - You are running inside RxCode, a desktop IDE that hosts multiple projects, \ - each with its own chat threads and agents. RxCode wires in a local MCP \ - server named `rxcode-ide`; its tools appear in your available tools list. \ - Use them to coordinate with other agents and to read editor state: - - - `ide__get_projects` — list every project registered in RxCode, so you \ - can discover sibling projects to read or message. - - `ide__get_threads` — list or natural-language search chat threads across \ - projects. - - `ide__get_thread_messages` — fetch the message history of a specific \ - thread by id. - - `ide__send_to_thread` — talk to another project's agent: send a prompt \ - to an existing thread or start a new thread in a project. This triggers a \ - real agent run that may consume tokens; it returns the other agent's reply. - - `ide__get_running_jobs` / `ide__get_job_output` — inspect run-profile \ - jobs executing in the IDE. - - `ide__get_usage` — current rate-limit / token usage. - - Reach for these when a task spans projects rather than guessing. Prefer \ - reading threads first; only use `ide__send_to_thread` when you actually \ - need another agent to act, since it costs tokens. - - RxCode also persists durable memories — stable user preferences, project \ - facts, and decisions — across sessions. Use these tools to recall and \ - store them: - - - `ide__memory_search` — before starting a task, search for saved \ - preferences, facts, or decisions relevant to it instead of asking the \ - user to repeat themselves. - - `ide__memory_add` — when the user states a stable preference, project \ - fact, or decision ("remember…", "from now on…", "always…"), save it. Set \ - `kind` (`preference`/`fact`/`decision`) and `scope` (`global` for \ - cross-project, `project` for repo-specific). - - `ide__memory_update` — when saved information changes, update the \ - existing entry by `id` rather than adding a duplicate. - - `ide__memory_delete` — remove a memory by `id` when it is no longer valid. - - Only store stable, reusable information in memory — not transient task \ - details. - """ - - /// True when the `rxcode-ide` MCP bridge is present in the `-c` overrides. - private static func includesIDEServer(_ overrides: [String]) -> Bool { - overrides.contains { $0.hasPrefix("mcp_servers.rxcode-ide=") } - } - - private static func codexApprovalPolicy(permissionMode: PermissionMode, planMode: Bool) -> String { - if planMode { return "on-request" } - switch permissionMode { - case .default, .plan: return "untrusted" - case .acceptEdits, .auto: return "on-request" - case .bypassPermissions: return "never" - } - } - - private static func codexSandboxMode(permissionMode: PermissionMode, planMode: Bool) -> String { - if planMode { return "read-only" } - switch permissionMode { - case .bypassPermissions: return "danger-full-access" - default: return "workspace-write" - } - } - - private static func codexSandboxPolicy(permissionMode: PermissionMode, planMode: Bool) -> JSONValue { - if planMode { - return .object(["type": .string("readOnly"), "networkAccess": .bool(false)]) - } - switch permissionMode { - case .bypassPermissions: - return .object(["type": .string("dangerFullAccess")]) - default: - return .object(["type": .string("workspaceWrite")]) - } - } - - private func spawnAppServer(binary: String, streamId: UUID, cwd: String?, configOverrides: [String] = []) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) { - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["app-server", "--listen", "stdio://"] + configOverrides - if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } - process.environment = await resolvedEnvironment() - - let stdin = Pipe() - let stdout = Pipe() - let stderr = Pipe() - process.standardInput = stdin - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - } catch { - throw CodexError.spawnFailed(error.localizedDescription) - } - - let stdinHandle = stdin.fileHandleForWriting - running[streamId] = RunningProcess(process: process, stdin: stdinHandle) - readStderr(stderr, streamId: streamId) - return (process, stdinHandle, stdout) - } - - private func readStderr(_ stderr: Pipe, streamId: UUID) { - Task.detached { [weak self] in - let data = stderr.fileHandleForReading.readDataToEndOfFile() - guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } - await self?.appendStderr(text, streamId: streamId) - } - } - - private func appendStderr(_ text: String, streamId: UUID) { - stderrBuffers[streamId, default: ""] += text - } - - private func findNvmCodexBinary(root: String) -> String? { - let fm = FileManager.default - guard let versions = try? fm.contentsOfDirectory(atPath: root) else { return nil } - for version in versions.sorted(by: >) { - let candidate = "\(root)/\(version)/bin/codex" - let resolved = (candidate as NSString).resolvingSymlinksInPath - if fm.fileExists(atPath: resolved) && fm.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private func resolvedEnvironment() async -> [String: String] { - var env = ProcessInfo.processInfo.environment - if let cachedShellPath { - env["PATH"] = cachedShellPath - return env - } - let rawShellPath = try? await runShellCommand("/bin/zsh", arguments: ["-ilc", "print -rn -- $PATH"], injectPath: false) - let shellPath = rawShellPath?.trimmingCharacters(in: .whitespacesAndNewlines) - if let shellPath, !shellPath.isEmpty { - cachedShellPath = shellPath - env["PATH"] = shellPath - } - return env - } - - private func runShellCommand(_ executable: String, arguments: [String], injectPath: Bool = true) async throws -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - if injectPath { - process.environment = await resolvedEnvironment() - } - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - let out = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - if process.terminationStatus != 0 { - let err = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - throw CodexError.versionCheckFailed(err.isEmpty ? out : err) - } - return out - } - - private func cleanTitle(_ raw: String) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - - let lower = cleaned.lowercased() - let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(80)) - } - - private func cleanNotificationSummary(_ raw: String) -> String? { - cleanSummary(raw, limit: 180) - } - - private func cleanSummary(_ raw: String, limit: Int) -> String? { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) - guard !cleaned.isEmpty else { return nil } - - let lower = cleaned.lowercased() - let errorPrefixes = ["api error", "error:", "execution error", "request failed", "codex error"] - guard !errorPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil } - return String(cleaned.prefix(limit)) - } - - private func toolName(from item: [String: JSONValue]) -> String? { - if let type = Self.firstString(in: item, keys: ["type", "kind"]) { - let normalizedType = type.lowercased() - if normalizedType.contains("command") { return "Bash" } - if normalizedType.contains("file") || normalizedType.contains("patch") { return "Edit" } - if normalizedType.contains("message") { return "message" } - return type - } - guard let name = Self.firstString(in: item, keys: ["name", "toolName"]) else { return nil } - return name.lowercased().contains("message") ? "message" : name - } - - private static func parseModels(from value: JSONValue) -> [AgentModel] { - let root = value.objectValue - let rawModels = root?["data"]?.arrayValue ?? root?["models"]?.arrayValue ?? root?["items"]?.arrayValue ?? value.arrayValue ?? [] - return rawModels.compactMap { entry in - if let id = entry.stringValue { - return AgentModel(provider: .codex, id: id, displayName: AppStateModelFormatter.codexDisplayName(id), description: "Codex model served by the Codex app-server.") - } - guard let object = entry.objectValue, - let id = firstString(in: object, keys: ["id", "slug", "model", "name"]) else { return nil } - if object["hidden"]?.boolValue == true || object["visibility"]?.stringValue == "hidden" { - return nil - } - let displayName = firstString(in: object, keys: ["displayName", "display_name", "name"]) ?? AppStateModelFormatter.codexDisplayName(id) - let description = firstString(in: object, keys: ["description", "detail"]) ?? "Codex model served by the Codex app-server." - return AgentModel(provider: .codex, id: id, displayName: displayName, description: description) - } - } - - private static func request(id: Int, method: String, params: [String: JSONValue]) -> JSONValue { - request(id: id, method: method, params: .object(params)) - } - - private static func request(id: Int, method: String, params: JSONValue) -> JSONValue { - .object(["jsonrpc": .string("2.0"), "id": .number(Double(id)), "method": .string(method), "params": params]) - } - - private static func response(id: String, result: [String: JSONValue]) -> JSONValue { - let idValue = Double(id).map(JSONValue.number) ?? .string(id) - return .object(["jsonrpc": .string("2.0"), "id": idValue, "result": .object(result)]) - } - - private static func notification(method: String, params: [String: JSONValue]) -> JSONValue { - .object(["jsonrpc": .string("2.0"), "method": .string(method), "params": .object(params)]) - } - - private static func writeJSONLine(_ value: JSONValue, to handle: FileHandle) throws { - let data = try JSONEncoder().encode(value) - try handle.write(contentsOf: data) - try handle.write(contentsOf: Data([0x0A])) - } - - private static func decodeObject(_ line: String) -> [String: JSONValue]? { - guard let data = line.data(using: .utf8), - let value = try? JSONDecoder().decode(JSONValue.self, from: data) else { return nil } - return value.objectValue - } - - private static func decodeJSONValue(_ text: String) -> JSONValue? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let data = trimmed.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(JSONValue.self, from: data) - } - - private static func idString(_ value: JSONValue?) -> String? { - if let s = value?.stringValue { return s } - if let n = value?.numberValue { - if n.rounded() == n { return String(Int(n)) } - return String(n) - } - return nil - } - - private static func threadId(from value: JSONValue) -> String? { - if let object = value.objectValue { - if let id = firstString(in: object, keys: ["threadId", "thread_id", "id"]) { return id } - if let nested = object["thread"]?.objectValue { - return firstString(in: nested, keys: ["threadId", "thread_id", "id"]) - } - } - return value.stringValue - } - - private static func firstString(in object: [String: JSONValue], keys: [String]) -> String? { - for key in keys { - if let value = object[key]?.stringValue { return value } - if let number = object[key]?.numberValue { return String(number) } - } - return nil - } - - private struct CodexRateLimitWindow { - let percent: Double - let resetsAt: Date? - let durationMinutes: Int? - } - - private static func parseCodexRateLimits(from value: JSONValue) -> RateLimitUsage? { - guard let snapshot = codexRateLimitSnapshot(from: value) else { return nil } - let windows = [ - parseCodexRateLimitWindow(snapshot["primary"]), - parseCodexRateLimitWindow(snapshot["secondary"]) - ].compactMap { $0 } - - guard !windows.isEmpty else { return nil } - let fiveHour = windows.first { $0.durationMinutes == 300 } ?? windows.first - let twentyFourHour = windows.first { $0.durationMinutes == 1_440 } - ?? windows.first { $0.durationMinutes != fiveHour?.durationMinutes } - ?? windows.dropFirst().first - - return RateLimitUsage( - fiveHourPercent: fiveHour?.percent ?? 0, - sevenDayPercent: 0, - twentyFourHourPercent: twentyFourHour?.percent, - fiveHourResetsAt: fiveHour?.resetsAt, - sevenDayResetsAt: nil, - twentyFourHourResetsAt: twentyFourHour?.resetsAt - ) - } - - private static func codexRateLimitSnapshot(from value: JSONValue) -> [String: JSONValue]? { - guard let object = value.objectValue else { return nil } - - if let byLimitId = object["rateLimitsByLimitId"]?.objectValue { - if let codex = byLimitId["codex"]?.objectValue { - return codex - } - for snapshot in byLimitId.values.compactMap(\.objectValue) { - if firstString(in: snapshot, keys: ["limitId", "limit_id"]) == "codex" { - return snapshot - } - } - } - - if let rateLimits = object["rateLimits"]?.objectValue { - return rateLimits - } - - if object["primary"] != nil || object["secondary"] != nil { - return object - } - - return nil - } - - private static func parseCodexRateLimitWindow(_ value: JSONValue?) -> CodexRateLimitWindow? { - guard let object = value?.objectValue else { return nil } - let percent = firstDouble(in: object, keys: ["usedPercent", "used_percent", "utilization"]) - let duration = firstOptionalInt(in: object, keys: ["windowDurationMins", "window_duration_mins", "windowMinutes"]) - return CodexRateLimitWindow( - percent: percent, - resetsAt: parseUnixOrISODate(object["resetsAt"] ?? object["resets_at"]), - durationMinutes: duration - ) - } - - private static func firstDouble(in object: [String: JSONValue], keys: [String]) -> Double { - for key in keys { - if let value = object[key]?.numberValue { return value } - if let value = object[key]?.stringValue, let doubleValue = Double(value) { return doubleValue } - } - return 0 - } - - private static func firstOptionalInt(in object: [String: JSONValue], keys: [String]) -> Int? { - for key in keys { - if let value = object[key]?.numberValue { return Int(value) } - if let value = object[key]?.stringValue, let intValue = Int(value) { return intValue } - } - return nil - } - - private static func parseUnixOrISODate(_ value: JSONValue?) -> Date? { - if let number = value?.numberValue { - let seconds = number > 1_000_000_000_000 ? number / 1_000 : number - return Date(timeIntervalSince1970: seconds) - } - guard let string = value?.stringValue else { return nil } - if let number = Double(string) { - let seconds = number > 1_000_000_000_000 ? number / 1_000 : number - return Date(timeIntervalSince1970: seconds) - } - return ISO8601DateFormatter().date(from: string) - } - - private static func usageInfo(from object: [String: JSONValue]) -> UsageInfo? { - let params = object["params"]?.objectValue ?? object - let usageKeys = ["usage", "tokenUsage", "token_usage", "totalUsage", "total_usage", "metrics"] - let usage = firstObject(in: params, keys: [ - "usage", "tokenUsage", "token_usage", "totalUsage", "total_usage", "metrics" - ]) ?? firstNestedObject(in: .object(params), keys: usageKeys) ?? params - - var inputTokens = firstInt(in: usage, keys: [ - "input_tokens", "inputTokens", "prompt_tokens", "promptTokens" - ]) - var outputTokens = firstInt(in: usage, keys: [ - "output_tokens", "outputTokens", "completion_tokens", "completionTokens" - ]) - outputTokens += firstInt(in: usage, keys: [ - "reasoning_output_tokens", "reasoningOutputTokens" - ]) - let cacheCreationTokens = firstInt(in: usage, keys: [ - "cache_creation_input_tokens", "cacheCreationInputTokens", "cacheWriteInputTokens", - "cached_creation_tokens", "cachedCreationTokens" - ]) - let explicitCacheReadTokens = firstInt(in: usage, keys: [ - "cache_read_input_tokens", "cacheReadInputTokens", "cached_input_tokens", - "cachedInputTokens", "cache_read_tokens", "cacheReadTokens" - ]) - let cacheReadTokens = explicitCacheReadTokens > 0 ? explicitCacheReadTokens : nestedInputCacheReadTokens(in: usage) - - let total = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens - let totalTokens = firstInt(in: usage, keys: ["total_tokens", "totalTokens"]) - if total == 0, totalTokens > 0 { - inputTokens = totalTokens - } - - guard inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens > 0 else { - return nil - } - return UsageInfo( - inputTokens: inputTokens, - outputTokens: outputTokens, - cacheCreationInputTokens: cacheCreationTokens, - cacheReadInputTokens: cacheReadTokens - ) - } - - private static func liveTokenUsage(from params: [String: JSONValue]) -> UsageInfo? { - guard let outputTokens = tokenUsageOutputTokens(from: params), - outputTokens > 0 else { return nil } - return UsageInfo( - inputTokens: 0, - outputTokens: outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0 - ) - } - - private static func tokenUsageOutputTokens(from params: [String: JSONValue]) -> Int? { - let usage = tokenUsageSummary(from: params) - let outputTokens = firstInt(in: usage, keys: ["outputTokens", "output_tokens"]) - let reasoningOutputTokens = firstInt(in: usage, keys: [ - "reasoningOutputTokens", "reasoning_output_tokens" - ]) - let total = outputTokens + reasoningOutputTokens - return total > 0 ? total : nil - } - - private static func tokenUsageTokensUsed(from params: [String: JSONValue]) -> Int? { - let usage = tokenUsageSummary(from: params) - let tokensUsed = firstInt(in: usage, keys: [ - "tokensUsed", "tokens_used", "totalTokens", "total_tokens", - "total", "used", "currentTokens", "current_tokens" - ]) - return tokensUsed > 0 ? tokensUsed : nil - } - - private static func tokenUsageTokenBudget(from params: [String: JSONValue]) -> Int? { - let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) - ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]) - ?? params - let tokenBudget = firstInt(in: usage, keys: [ - "tokenBudget", "token_budget", "modelContextWindow", "model_context_window" - ]) - return tokenBudget > 0 ? tokenBudget : nil - } - - private static func tokenUsageSummary(from params: [String: JSONValue]) -> [String: JSONValue] { - let usage = firstObject(in: params, keys: ["tokenUsage", "token_usage"]) - ?? firstNestedObject(in: .object(params), keys: ["tokenUsage", "token_usage"]) - ?? params - return firstObject(in: usage, keys: ["last"]) - ?? firstObject(in: usage, keys: ["total"]) - ?? usage - } - - private static func firstObject(in object: [String: JSONValue], keys: [String]) -> [String: JSONValue]? { - for key in keys { - if let value = object[key]?.objectValue { return value } - } - return nil - } - - private static func firstNestedObject(in value: JSONValue, keys: [String]) -> [String: JSONValue]? { - if let object = value.objectValue { - if let direct = firstObject(in: object, keys: keys) { return direct } - for nested in object.values { - if let found = firstNestedObject(in: nested, keys: keys) { return found } - } - } - if let array = value.arrayValue { - for nested in array { - if let found = firstNestedObject(in: nested, keys: keys) { return found } - } - } - return nil - } - - private static func firstInt(in object: [String: JSONValue], keys: [String]) -> Int { - for key in keys { - if let value = object[key]?.numberValue { return Int(value) } - if let value = object[key]?.stringValue, let intValue = Int(value) { return intValue } - } - return 0 - } - - private static func nestedInputCacheReadTokens(in usage: [String: JSONValue]) -> Int { - guard let details = firstObject(in: usage, keys: [ - "input_tokens_details", "inputTokensDetails", "prompt_tokens_details", "promptTokensDetails" - ]) else { return 0 } - return firstInt(in: details, keys: [ - "cached_tokens", "cachedTokens", "cache_read_input_tokens", "cacheReadInputTokens" - ]) - } - - private static func usageMessageId(from params: [String: JSONValue], fallback: String?) -> String { - firstString(in: params, keys: [ - "messageId", "message_id", "responseId", "response_id", - "turnId", "turn_id", "itemId", "item_id", "id" - ]) ?? fallback ?? "codex-turn" - } - - private static func claudeTextDelta(_ text: String) -> String { - jsonString([ - "type": "content_block_delta", - "delta": ["type": "text_delta", "text": text] - ]) - } - - private static func claudeThinkingDelta(_ text: String) -> String { - jsonString([ - "type": "content_block_delta", - "delta": ["type": "thinking_delta", "thinking": text] - ]) - } - - private static func claudeToolStart(id: String, name: String) -> String { - jsonString([ - "type": "content_block_start", - "content_block": ["type": "tool_use", "id": id, "name": name] - ]) - } - - private static func claudeInputDelta(_ input: [String: JSONValue]) -> String { - let data = (try? JSONEncoder().encode(JSONValue.object(input))) ?? Data("{}".utf8) - let inputText = String(data: data, encoding: .utf8) ?? "{}" - return jsonString([ - "type": "content_block_delta", - "delta": ["type": "input_json_delta", "partial_json": inputText] - ]) - } - - private static func claudeContentBlockStop() -> String { - jsonString(["type": "content_block_stop"]) - } - - private static func jsonString(_ object: [String: Any]) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: object), - let text = String(data: data, encoding: .utf8) else { return "{}" } - return text - } } -private enum AppStateModelFormatter { +enum AppStateModelFormatter { nonisolated static func codexDisplayName(_ model: String) -> String { model .replacingOccurrences(of: "-", with: " ") diff --git a/RxCode/Services/MobileSyncService+EventDispatch.swift b/RxCode/Services/MobileSyncService+EventDispatch.swift new file mode 100644 index 0000000..6c690fb --- /dev/null +++ b/RxCode/Services/MobileSyncService+EventDispatch.swift @@ -0,0 +1,363 @@ +import Foundation +import AppKit +import Combine +import CryptoKit +import RxCodeCore +import RxCodeSync +import os.log + +extension MobileSyncService { + + // MARK: - Event dispatch + + func handle(event: RelayClient.Event) { + switch event { + case .stateChanged(let s): + connectionState = s + logger.info("[MobileSync] relay state=\(String(describing: s), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + case .deliveryFailed(let toHex): + // Drop-on-offline policy — desktop ignores, mobile will resync on + // next reconnect. + logger.warning("[MobileSync] relay delivery failed to mobileKey=\(String(toHex.prefix(12)), privacy: .public)") + case .inbound(let inbound): + handleInbound(inbound) + } + } + + func handleInbound(_ inbound: RelayClient.Inbound) { + switch inbound.payload { + case .pairRequest(let req): + pendingPairing = req + Task { try? await client.addPeer(req.mobilePubkeyHex) } + case .unpair: + guard isPairedPeer(inbound.fromHex) else { return } + handleRemoteUnpair(pubkeyHex: inbound.fromHex) + case .apnsToken(let t): + if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { + logger.info("[APNs] token received mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") + pairedDevices[idx].apnsToken = t.tokenHex + pairedDevices[idx].apnsEnvironment = t.environment + pairedDevices[idx].lastSeen = .now + for staleIdx in pairedDevices.indices where staleIdx != idx && pairedDevices[staleIdx].apnsToken == t.tokenHex { + logger.warning("[APNs] clearing duplicate token from stale mobileKey=\(String(self.pairedDevices[staleIdx].pubkeyHex.prefix(12)), privacy: .public) currentMobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public)") + pairedDevices[staleIdx].apnsToken = nil + pairedDevices[staleIdx].apnsEnvironment = nil + } + savePairedDevices() + } else { + logger.warning("[APNs] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") + } + case .liveActivityToken(let t): + guard let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) else { + logger.warning("[LiveActivity] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + return + } + if let startToken = t.pushToStartTokenHex, !startToken.isEmpty { + pairedDevices[idx].liveActivityStartToken = startToken + logger.info("[LiveActivity] push-to-start token registered mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } + if t.activityDismissed == true { + // The user swiped the aggregate Live Activity away. Forget + // this device's update token; once no device tracks the + // activity the next job push-to-starts a fresh one. + if var refs = pairedDevices[idx].liveActivityTokens { + refs.removeAll { t.activityID == nil || $0.activityID == t.activityID } + pairedDevices[idx].liveActivityTokens = refs.isEmpty ? nil : refs + } + if !hasAnyActivityToken { + jobsActivityLocallyStarted = false + lastPushedJobsSignature = "" + cancelJobsActivityStart() + } + logger.info("[LiveActivity] aggregate activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + } else if t.activityStartedLocally == true { + // A foregrounded device started the activity itself with + // `Activity.request` and reported it the instant it was + // created — long before APNs mints the update token. Cancel + // the deferred push-to-start so iOS never spawns a duplicate. + jobsActivityLocallyStarted = true + cancelJobsActivityStart() + logger.info("[LiveActivity] device started aggregate activity locally mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) — deferred push-to-start cancelled") + } else if let activityToken = t.activityTokenHex, !activityToken.isEmpty, + let activityID = t.activityID { + cancelJobsActivityStart() + if trackedJobs.isEmpty { + // This (fresh) desktop process tracks no jobs, yet the + // device still has a job activity registered — it is an + // orphan left behind by a previous desktop session that + // crashed or quit mid-job and never delivered the + // finishing update. End it so it doesn't sit stuck on + // "Working" forever, and drop the now-dead token. + logger.info("[LiveActivity] activity token from device with no tracked jobs — ending orphaned activity activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + sendJobsActivityEnd(deviceToken: activityToken, device: pairedDevices[idx]) + pairedDevices[idx].liveActivityTokens = nil + } else { + // One aggregate activity per device — replace any prior token. + pairedDevices[idx].liveActivityTokens = [ + LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) + ] + logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + // Push the latest known state straight away so a freshly + // started activity isn't left blank until the next change. + lastPushedJobsSignature = jobsSignature + sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) + } + } + pairedDevices[idx].lastSeen = .now + savePairedDevices() + case .requestSnapshot(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "request_snapshot") else { return } + logger.info("[MobileSync] snapshot requested by mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) activeSession=\(req.activeSessionID ?? "", privacy: .public)") + // AppState owns the data; it observes pendingSnapshotRequests + // and replies. Stub for now — wired up by AppState bridge. + var userInfo: [String: Any] = ["from": inbound.fromHex] + userInfo["activeSessionID"] = req.activeSessionID + NotificationCenter.default.post( + name: .mobileSyncSnapshotRequested, + object: nil, + userInfo: userInfo + ) + case .settingsUpdate(let update): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "settings_update") else { return } + NotificationCenter.default.post( + name: .mobileSyncSettingsUpdateReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": update] + ) + case .userMessage(let msg): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "user_message") else { return } + NotificationCenter.default.post( + name: .mobileSyncUserMessageReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": msg] + ) + case .cancelStream(let cancel): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "cancel_stream") else { return } + NotificationCenter.default.post( + name: .mobileSyncCancelStreamRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": cancel] + ) + case .removeQueuedMessage(let payload): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "remove_queued_message") else { return } + NotificationCenter.default.post( + name: .mobileSyncRemoveQueuedRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": payload] + ) + case .newSessionRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "new_session_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncNewSessionRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .threadActionRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_action_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncThreadActionRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .loadMoreMessages(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "load_more_messages") else { return } + NotificationCenter.default.post( + name: .mobileSyncLoadMoreMessagesRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .searchRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "search_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncSearchRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .threadChangesRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_changes_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncThreadChangesRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .subscribeSession(let sub): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "subscribe_session") else { return } + subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" + var userInfo: [String: Any] = ["from": inbound.fromHex] + userInfo["activeSessionID"] = sub.sessionID + NotificationCenter.default.post( + name: .mobileSyncSnapshotRequested, + object: nil, + userInfo: userInfo + ) + case .permissionResponse(let resp): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "permission_response") else { return } + NotificationCenter.default.post( + name: .mobileSyncPermissionResponse, + object: nil, + userInfo: ["payload": resp] + ) + case .questionAnswer(let answer): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "question_answer") else { return } + NotificationCenter.default.post( + name: .mobileSyncQuestionAnswerReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": answer] + ) + case .planDecision(let decision): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "plan_decision") else { return } + NotificationCenter.default.post( + name: .mobileSyncPlanDecisionReceived, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": decision] + ) + case .branchOpRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "branch_op_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncBranchOpRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .folderTreeRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "folder_tree_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncFolderTreeRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .createProjectRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "create_project_request") else { return } + NotificationCenter.default.post( + name: .mobileSyncCreateProjectRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .runProfileMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_mutation_request") else { return } + logger.info("[MobileSync] run profile mutation requested operation=\(req.operation.rawValue, privacy: .public) project=\(req.projectID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunProfileMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .runProfileRunRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_run_request") else { return } + logger.info("[MobileSync] run profile start requested project=\(req.projectID.uuidString, privacy: .public) profile=\(req.profileID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunProfileRunRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .runProfileStopRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_stop_request") else { return } + logger.info("[MobileSync] run profile stop requested task=\(req.taskID?.uuidString ?? "", privacy: .public) project=\(req.projectID?.uuidString ?? "", privacy: .public) profile=\(req.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncRunProfileStopRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .skillCatalogRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_catalog_request") else { return } + logger.info("[MobileSync] skill catalog requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillCatalogRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .skillMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_mutation_request") else { return } + logger.info("[MobileSync] skill mutation requested operation=\(req.operation.rawValue, privacy: .public) plugin=\(req.pluginID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .skillSourceMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_source_mutation_request") else { return } + logger.info("[MobileSync] skill source mutation requested operation=\(req.operation.rawValue, privacy: .public) source=\(req.sourceID ?? req.gitURL ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncSkillSourceMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .acpRegistryRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_registry_request") else { return } + logger.info("[MobileSync] acp registry requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncACPRegistryRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .acpMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_mutation_request") else { return } + logger.info("[MobileSync] acp mutation requested operation=\(req.operation.rawValue, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncACPMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .mcpConfigRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_config_request") else { return } + logger.info("[MobileSync] mcp config requested mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncMCPConfigRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .mcpMutationRequest(let req): + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_mutation_request") else { return } + logger.info("[MobileSync] mcp mutation requested operation=\(req.operation.rawValue, privacy: .public) server=\(req.serverName, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + NotificationCenter.default.post( + name: .mobileSyncMCPMutationRequested, + object: nil, + userInfo: ["from": inbound.fromHex, "payload": req] + ) + case .ping: + guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } + Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } + default: + break + } + } + + func isPairedPeer(_ pubkeyHex: String) -> Bool { + pairedDevices.contains { $0.pubkeyHex == pubkeyHex } + } + + func acceptPairedOnlyPayload(from pubkeyHex: String, type: String) -> Bool { + guard isPairedPeer(pubkeyHex) else { + logger.warning("[MobileSync] rejecting \(type, privacy: .public) from unknown mobileKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") + Task { + try? await client.addPeer(pubkeyHex) + try? await client.send(.unpair(UnpairPayload(reason: "unknown_peer")), toHex: pubkeyHex) + await client.removePeer(pubkeyHex) + } + return false + } + return true + } + + func handleRemoteUnpair(pubkeyHex: String) { + guard pairedDevices.contains(where: { $0.pubkeyHex == pubkeyHex }) else { + Task { await client.removePeer(pubkeyHex) } + return + } + + pairedDevices.removeAll { $0.pubkeyHex == pubkeyHex } + savePairedDevices() + subscribedSessions.removeValue(forKey: pubkeyHex) + logger.info("[MobileSync] removed paired device after remote unpair") + Task { await client.removePeer(pubkeyHex) } + } + + /// Send a payload to a single peer (used by AppState when replying to + /// `request_snapshot` etc). + func send(_ payload: Payload, toHex hex: String) async { + do { + try await client.send(payload, toHex: hex) + logger.debug("[MobileSync] sent type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") + } catch { + logger.error("[MobileSync] send failed type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/RxCode/Services/MobileSyncService+LiveActivity.swift b/RxCode/Services/MobileSyncService+LiveActivity.swift new file mode 100644 index 0000000..7328a34 --- /dev/null +++ b/RxCode/Services/MobileSyncService+LiveActivity.swift @@ -0,0 +1,383 @@ +import Foundation +import AppKit +import Combine +import CryptoKit +import RxCodeCore +import RxCodeSync +import os.log + +extension MobileSyncService { + + // MARK: - Live Activity & widget push + + /// Fold a session update into the streaming-job set and the aggregate Live + /// Activity, then push any resulting Live Activity / widget changes. + /// Called for every `broadcastSessionUpdate`. + func updateJobTracking( + sessionID: String, + kind: SessionUpdatePayload.Kind, + isStreaming: Bool?, + summary: RxCodeSync.SessionSummary?, + previousSessionID: String? + ) { + if let previousSessionID, previousSessionID != sessionID { + streamingSessionIDs.remove(previousSessionID) + if let previousIdx = trackedJobs.firstIndex(where: { $0.sessionID == previousSessionID }) { + if let summary { + trackedJobs[previousIdx] = makeJobContent(from: summary) + } else { + trackedJobs.remove(at: previousIdx) + } + lastPushedJobsSignature = "" + } + } + let streaming: Bool? + switch kind { + case .streamingStarted: streaming = true + case .streamingFinished: streaming = false + default: streaming = isStreaming + } + if let streaming { + if streaming { streamingSessionIDs.insert(sessionID) } + else { streamingSessionIDs.remove(sessionID) } + } + // Summaries carry title/progress/todos — they drive the Live Activity. + if let summary { + foldSummaryIntoJobs(summary) + pushJobsActivity() + } + pushWidgetUpdateIfJobCountChanged() + } + + /// Merge one session summary into `trackedJobs`. + /// + /// A running session is inserted or updated. A finished session updates + /// the job only if it is already tracked, and is otherwise ignored — the + /// aggregate activity follows jobs it saw start. When a new job begins + /// while every tracked job is already done, the previous (acknowledged) + /// batch is cleared so the activity starts a fresh list. + func foldSummaryIntoJobs(_ summary: RxCodeSync.SessionSummary) { + let content = makeJobContent(from: summary) + if let idx = trackedJobs.firstIndex(where: { $0.sessionID == summary.id }) { + trackedJobs[idx] = content + } else if summary.isStreaming { + if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) { + trackedJobs.removeAll() + lastPushedJobsSignature = "" + } + trackedJobs.append(content) + } + pruneTrackedJobs() + } + + /// Cap the tracked-job list, dropping the oldest finished jobs first so a + /// long-lived device never accumulates an unbounded history. + func pruneTrackedJobs() { + let cap = 6 + while trackedJobs.count > cap { + if let doneIdx = trackedJobs.firstIndex(where: \.isDone) { + trackedJobs.remove(at: doneIdx) + } else { + trackedJobs.removeFirst() + } + } + } + + func makeJobContent(from summary: RxCodeSync.SessionSummary) -> JobContent { + JobContent( + sessionID: summary.id, + title: summary.title, + projectName: projectNameResolver?(summary.projectId) ?? "", + todoDone: summary.progress?.done ?? 0, + todoTotal: summary.progress?.total ?? 0, + currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm, + isDone: !summary.isStreaming + ) + } + + // MARK: Aggregate Live Activity + + /// Concatenated per-job signatures — identifies a distinct rendered state. + var jobsSignature: String { + trackedJobs.map(\.signature).joined(separator: ";") + } + + /// `true` once every tracked job has finished. + var allJobsDone: Bool { + !trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone) + } + + /// `true` when some paired device has registered the aggregate activity's + /// update token — i.e. the activity exists and can be pushed `update`s. + var hasAnyActivityToken: Bool { + pairedDevices.contains { !($0.liveActivityTokens ?? []).isEmpty } + } + + /// Drive the single aggregate Live Activity from `trackedJobs`. + /// + /// The activity is created once with a push-to-start and then reused for + /// the lifetime of the device session: it is never ended or auto-dismissed + /// by the desktop, only updated. One activity for every job keeps re-runs + /// off the scarce iOS push-to-start budget; the user dismisses it. + func pushJobsActivity() { + guard !trackedJobs.isEmpty else { return } + let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 + if hasAnyActivityToken { + let signature = jobsSignature + guard signature != lastPushedJobsSignature else { + logger.debug("[LiveActivity] jobs activity unchanged — skip update") + return + } + lastPushedJobsSignature = signature + logger.info("[LiveActivity] jobs activity update jobs=\(self.trackedJobs.count, privacy: .public) running=\(self.trackedJobs.filter { !$0.isDone }.count, privacy: .public)") + sendJobsActivityUpdate(staleAfter: staleAfter) + } else if jobsActivityLocallyStarted { + // The activity exists locally; its update token has not been + // minted yet. The first push goes out when that token registers. + logger.debug("[LiveActivity] jobs activity started locally — awaiting update token") + } else { + scheduleJobsActivityStart() + } + } + + /// Schedule the push-to-start after a short delay. A foregrounded device + /// starts the activity itself (no push-to-start budget) and reports it + /// within a second or two, cancelling this task. Only a backgrounded + /// device ends up actually receiving the push-to-start. + func scheduleJobsActivityStart() { + guard pendingStartTask == nil else { return } + logger.info("[LiveActivity] scheduling jobs activity push-to-start in 5s") + pendingStartTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self else { return } + self.pendingStartTask = nil + guard !self.trackedJobs.isEmpty else { return } + guard !self.hasAnyActivityToken, !self.jobsActivityLocallyStarted else { + self.logger.info("[LiveActivity] push-to-start skipped — a device already has the activity") + return + } + self.sendJobsActivityStart() + } + } + + /// Cancel a pending push-to-start — the activity already exists. + func cancelJobsActivityStart() { + if pendingStartTask != nil { + pendingStartTask?.cancel() + pendingStartTask = nil + logger.debug("[LiveActivity] pending push-to-start cancelled") + } + } + + /// Push a `start` for the aggregate activity to every device with a + /// push-to-start token. + func sendJobsActivityStart() { + let devices = pairedDevices.filter { ($0.liveActivityStartToken?.isEmpty == false) } + guard !devices.isEmpty else { + logger.warning("[LiveActivity] start skipped — no paired device has a push-to-start token (pairedDevices=\(self.pairedDevices.count, privacy: .public))") + return + } + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] start skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + let now = Date() + let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "start", + "content-state": jobsContentStateDict(at: now), + "attributes-type": "RxCodeJobActivityAttributes", + "attributes": [String: Any](), + "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), + ]] + lastPushedJobsSignature = jobsSignature + logger.info("[LiveActivity] start jobs activity devices=\(devices.count, privacy: .public) jobs=\(self.trackedJobs.count, privacy: .public)") + for device in devices { + guard let token = device.liveActivityStartToken else { continue } + logger.info("[LiveActivity] start → posting push startTokenPrefix=\(String(token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: token, pushType: "liveactivity", + apnsPayload: payload, collapseID: nil, device: device, pushURL: pushURL) + } + } + } + + /// Push an `update` for the aggregate activity. `staleAfter` sets when the + /// activity dims — long for the terminal all-done state, which stays on + /// screen until the user dismisses it. No `end` is ever sent. + func sendJobsActivityUpdate(staleAfter: TimeInterval) { + let now = Date() + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "update", + "content-state": jobsContentStateDict(at: now), + "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), + ]] + pushToActivityTokens(payload: payload) + } + + /// Push an `end` event that clears an orphaned aggregate activity — one + /// left running by a previous desktop session that crashed or quit + /// mid-job and never delivered the finishing update. `dismissal-date` is + /// set to now so iOS removes it promptly instead of letting it linger on + /// screen for the system's default window. + func sendJobsActivityEnd(deviceToken: String, device: PairedDevice) { + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] end skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + let now = Date() + let payload: [String: Any] = ["aps": [ + "timestamp": Int(now.timeIntervalSince1970), + "event": "end", + "content-state": ["jobs": [[String: Any]](), "updatedAt": now.timeIntervalSince1970], + "dismissal-date": Int(now.timeIntervalSince1970), + ]] + logger.info("[LiveActivity] end → posting push tokenPrefix=\(String(deviceToken.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: deviceToken, pushType: "liveactivity", + apnsPayload: payload, collapseID: "rxcode-jobs-activity", + device: device, pushURL: pushURL) + } + } + + /// Build the ActivityKit `content-state` dict. Field names mirror + /// `RxCodeJobActivityAttributes.ContentState` in the widget target. + func jobsContentStateDict(at date: Date) -> [String: Any] { + let jobs: [[String: Any]] = trackedJobs.map { job in + var dict: [String: Any] = [ + "id": job.sessionID, + "phase": job.isDone ? "done" : "running", + "title": job.title, + "projectName": job.projectName, + "todoDone": job.todoDone, + "todoTotal": job.todoTotal, + ] + if let step = job.currentStep, !step.isEmpty { + dict["currentStep"] = step + } + return dict + } + return ["jobs": jobs, "updatedAt": date.timeIntervalSince1970] + } + + /// Push a Live Activity payload to every registered aggregate-activity + /// token (one per paired device). + func pushToActivityTokens(payload: [String: Any]) { + guard let pushURL = Self.pushEndpointURL(from: relayURL) else { + logger.error("[LiveActivity] update skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") + return + } + var matched = 0 + for device in pairedDevices { + for ref in (device.liveActivityTokens ?? []) { + matched += 1 + logger.info("[LiveActivity] push → activity token activity=\(ref.activityID, privacy: .public) tokenPrefix=\(String(ref.token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + Task { + await postRawPush(deviceToken: ref.token, pushType: "liveactivity", + apnsPayload: payload, collapseID: "rxcode-jobs-activity", + device: device, pushURL: pushURL) + } + } + } + if matched == 0 { + logger.warning("[LiveActivity] update has no registered activity token — the mobile never reported one") + } + } + + func pushWidgetUpdateIfJobCountChanged() { + guard streamingSessionIDs.count != lastWidgetJobCount else { return } + pushWidgetUpdate() + } + + /// Push the current ongoing-job count and agent usage to every paired + /// device as a silent background notification, refreshing the home-screen + /// widget. Also called by `AppState` when rate-limit usage refreshes. + func pushWidgetUpdate() { + let jobCount = streamingSessionIDs.count + lastWidgetJobCount = jobCount + let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } + guard !devices.isEmpty, let pushURL = Self.pushEndpointURL(from: relayURL) else { return } + let usage = usageSnapshotProvider?() + var widget: [String: Any] = [ + "jobs": jobCount, + "updatedAt": Date().timeIntervalSince1970, + ] + if let cc = usage?.cc { widget["cc"] = cc } + if let codex = usage?.codex { widget["codex"] = codex } + let payload: [String: Any] = ["aps": ["content-available": 1], "widget": widget] + for device in devices { + guard let token = device.apnsToken else { continue } + Task { + await postRawPush(deviceToken: token, pushType: "background", + apnsPayload: payload, collapseID: "rxcode-widget", + device: device, pushURL: pushURL) + } + } + } + + /// POST a raw (Live Activity or background) push to the relay `/push` + /// endpoint. Failures are logged and swallowed — these are best-effort. + func postRawPush( + deviceToken: String, + pushType: String, + apnsPayload: [String: Any], + collapseID: String?, + device: PairedDevice, + pushURL: URL + ) async { + var bodyDict: [String: Any] = [ + "device_token": deviceToken, + "push_type": pushType, + "apns_payload": apnsPayload, + ] + if let collapseID { bodyDict["collapse_id"] = collapseID } + do { + let httpBody = try JSONSerialization.data(withJSONObject: bodyDict) + var request = URLRequest(url: pushURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { return } + guard (200..<300).contains(http.statusCode) else { + logger.error("[Push] \(pushType, privacy: .public) relay rejected status=\(http.statusCode, privacy: .public) body=\(Self.responseBodyString(data), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + return + } + if let pushResponse = try? JSONDecoder().decode(APNsPushResponse.self, from: data) { + if (200..<300).contains(pushResponse.statusCode) { + logger.info("[Push] \(pushType, privacy: .public) accepted apnsStatus=\(pushResponse.statusCode, privacy: .public) apnsID=\(pushResponse.apnsID ?? "", privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } else { + logger.error("[Push] \(pushType, privacy: .public) apns rejected status=\(pushResponse.statusCode, privacy: .public) reason=\(pushResponse.reason, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } + } else { + logger.info("[Push] \(pushType, privacy: .public) relay accepted httpStatus=\(http.statusCode, privacy: .public) (no APNs detail in response) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") + } + } catch { + logger.error("[Push] \(pushType, privacy: .public) failed deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } +} + +/// Latest content the desktop knows for one job in the aggregate Live +/// Activity. Stored in `MobileSyncService.trackedJobs` in start order. +struct JobContent { + var sessionID: String + var title: String + var projectName: String + var todoDone: Int + var todoTotal: Int + var currentStep: String? + /// `true` once the job has finished. It shows the "done" phase but stays + /// in the aggregate list so the activity can report the completed batch. + var isDone: Bool + + /// Identifies a distinct rendered state for one job, so an update only + /// pushes on a real change rather than on every session event. Includes + /// `title` so the activity refreshes when the desktop swaps in an + /// AI-summarized title. + var signature: String { + "\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")" + } +} diff --git a/RxCode/Services/MobileSyncService.swift b/RxCode/Services/MobileSyncService.swift index 29c985a..abaad6a 100644 --- a/RxCode/Services/MobileSyncService.swift +++ b/RxCode/Services/MobileSyncService.swift @@ -73,23 +73,23 @@ final class MobileSyncService: ObservableObject { static let shared = MobileSyncService() - @Published private(set) var pairedDevices: [PairedDevice] = [] - @Published private(set) var connectionState: RelayClient.ConnectionState = .disconnected - @Published private(set) var isPairing: Bool = false - @Published private(set) var pendingPairing: PairRequestPayload? + @Published var pairedDevices: [PairedDevice] = [] + @Published var connectionState: RelayClient.ConnectionState = .disconnected + @Published var isPairing: Bool = false + @Published var pendingPairing: PairRequestPayload? @Published var relayURL: URL - private let logger = Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") - private let identity: DeviceIdentity - private var client: SyncClient - private var subscribedSessions: [String: String] = [:] - private var eventTask: Task? - private var pairingToken: PairingToken? - private var pairingContinuation: CheckedContinuation? + let logger = Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") + let identity: DeviceIdentity + var client: SyncClient + var subscribedSessions: [String: String] = [:] + var eventTask: Task? + var pairingToken: PairingToken? + var pairingContinuation: CheckedContinuation? /// The single AppState reference is set in init order from RxCodeApp, /// because AppState owns the storage layer and the streaming loop. - private weak var appState: AnyObject? + weak var appState: AnyObject? // MARK: - Live Activity & widget state @@ -101,22 +101,22 @@ final class MobileSyncService: ObservableObject { var usageSnapshotProvider: (@MainActor () -> (cc: Double?, codex: Double?))? /// Session ids currently streaming — the live job count for the widget. - private var streamingSessionIDs: Set = [] + var streamingSessionIDs: Set = [] /// Every job tracked by the single aggregate Live Activity: those still /// running plus recently finished ones, in start order. - private var trackedJobs: [JobContent] = [] + var trackedJobs: [JobContent] = [] /// `true` once a foregrounded device reported it started the activity /// locally; suppresses the push-to-start until the activity goes away. - private var jobsActivityLocallyStarted = false + var jobsActivityLocallyStarted = false /// Signature of the content-state last pushed, so an update only fires on /// a real change rather than on every session event. - private var lastPushedJobsSignature = "" + var lastPushedJobsSignature = "" /// Pending deferred push-to-start. The push-to-start is delayed briefly so /// a foregrounded device can start the activity locally instead; this task /// is cancelled once a device reports it did. - private var pendingStartTask: Task? + var pendingStartTask: Task? /// Last widget job count pushed, so a widget push only fires on a change. - private var lastWidgetJobCount: Int = -1 + var lastWidgetJobCount: Int = -1 init() { // Persisted relay URL or sensible default for self-host. @@ -133,7 +133,7 @@ final class MobileSyncService: ObservableObject { loadPairedDevices() } - private static func logFatalKeychain(_ error: Error) { + static func logFatalKeychain(_ error: Error) { Logger(subsystem: "com.idealapp.RxCode", category: "MobileSync") .fault("device-identity load failed: \(String(describing: error))") } @@ -283,7 +283,7 @@ final class MobileSyncService: ObservableObject { /// Best-effort APNs fan-out: submit one encrypted alert per paired device /// that has registered a push token. Per-device failures are logged and /// swallowed — the live channel above remains the primary path. - private func fanoutAPNs(_ payload: NotificationPayload) async { + func fanoutAPNs(_ payload: NotificationPayload) async { let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } guard !devices.isEmpty else { return } guard let pushURL = Self.pushEndpointURL(from: relayURL) else { @@ -302,7 +302,7 @@ final class MobileSyncService: ObservableObject { /// Encrypt `payload` for one device and POST it to the relay `/push` /// endpoint. Throws on a missing token, unknown peer, or relay/APNs /// rejection so callers can log the specific failure. - private func sendAPNsPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { + func sendAPNsPush(_ payload: NotificationPayload, to device: PairedDevice, pushURL: URL) async throws { guard let token = device.apnsToken, !token.isEmpty else { throw MobilePushError.missingDeviceToken } @@ -461,724 +461,20 @@ final class MobileSyncService: ObservableObject { } } - // MARK: - Live Activity & widget push - - /// Fold a session update into the streaming-job set and the aggregate Live - /// Activity, then push any resulting Live Activity / widget changes. - /// Called for every `broadcastSessionUpdate`. - private func updateJobTracking( - sessionID: String, - kind: SessionUpdatePayload.Kind, - isStreaming: Bool?, - summary: RxCodeSync.SessionSummary?, - previousSessionID: String? - ) { - if let previousSessionID, previousSessionID != sessionID { - streamingSessionIDs.remove(previousSessionID) - if let previousIdx = trackedJobs.firstIndex(where: { $0.sessionID == previousSessionID }) { - if let summary { - trackedJobs[previousIdx] = makeJobContent(from: summary) - } else { - trackedJobs.remove(at: previousIdx) - } - lastPushedJobsSignature = "" - } - } - let streaming: Bool? - switch kind { - case .streamingStarted: streaming = true - case .streamingFinished: streaming = false - default: streaming = isStreaming - } - if let streaming { - if streaming { streamingSessionIDs.insert(sessionID) } - else { streamingSessionIDs.remove(sessionID) } - } - // Summaries carry title/progress/todos — they drive the Live Activity. - if let summary { - foldSummaryIntoJobs(summary) - pushJobsActivity() - } - pushWidgetUpdateIfJobCountChanged() - } - - /// Merge one session summary into `trackedJobs`. - /// - /// A running session is inserted or updated. A finished session updates - /// the job only if it is already tracked, and is otherwise ignored — the - /// aggregate activity follows jobs it saw start. When a new job begins - /// while every tracked job is already done, the previous (acknowledged) - /// batch is cleared so the activity starts a fresh list. - private func foldSummaryIntoJobs(_ summary: RxCodeSync.SessionSummary) { - let content = makeJobContent(from: summary) - if let idx = trackedJobs.firstIndex(where: { $0.sessionID == summary.id }) { - trackedJobs[idx] = content - } else if summary.isStreaming { - if !trackedJobs.isEmpty, trackedJobs.allSatisfy(\.isDone) { - trackedJobs.removeAll() - lastPushedJobsSignature = "" - } - trackedJobs.append(content) - } - pruneTrackedJobs() - } - - /// Cap the tracked-job list, dropping the oldest finished jobs first so a - /// long-lived device never accumulates an unbounded history. - private func pruneTrackedJobs() { - let cap = 6 - while trackedJobs.count > cap { - if let doneIdx = trackedJobs.firstIndex(where: \.isDone) { - trackedJobs.remove(at: doneIdx) - } else { - trackedJobs.removeFirst() - } - } - } - - private func makeJobContent(from summary: RxCodeSync.SessionSummary) -> JobContent { - JobContent( - sessionID: summary.id, - title: summary.title, - projectName: projectNameResolver?(summary.projectId) ?? "", - todoDone: summary.progress?.done ?? 0, - todoTotal: summary.progress?.total ?? 0, - currentStep: summary.todos?.first { $0.status == .inProgress }?.activeForm, - isDone: !summary.isStreaming - ) - } - - // MARK: Aggregate Live Activity - - /// Concatenated per-job signatures — identifies a distinct rendered state. - private var jobsSignature: String { - trackedJobs.map(\.signature).joined(separator: ";") - } - - /// `true` once every tracked job has finished. - private var allJobsDone: Bool { - !trackedJobs.isEmpty && trackedJobs.allSatisfy(\.isDone) - } - - /// `true` when some paired device has registered the aggregate activity's - /// update token — i.e. the activity exists and can be pushed `update`s. - private var hasAnyActivityToken: Bool { - pairedDevices.contains { !($0.liveActivityTokens ?? []).isEmpty } - } - - /// Drive the single aggregate Live Activity from `trackedJobs`. - /// - /// The activity is created once with a push-to-start and then reused for - /// the lifetime of the device session: it is never ended or auto-dismissed - /// by the desktop, only updated. One activity for every job keeps re-runs - /// off the scarce iOS push-to-start budget; the user dismisses it. - private func pushJobsActivity() { - guard !trackedJobs.isEmpty else { return } - let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 - if hasAnyActivityToken { - let signature = jobsSignature - guard signature != lastPushedJobsSignature else { - logger.debug("[LiveActivity] jobs activity unchanged — skip update") - return - } - lastPushedJobsSignature = signature - logger.info("[LiveActivity] jobs activity update jobs=\(self.trackedJobs.count, privacy: .public) running=\(self.trackedJobs.filter { !$0.isDone }.count, privacy: .public)") - sendJobsActivityUpdate(staleAfter: staleAfter) - } else if jobsActivityLocallyStarted { - // The activity exists locally; its update token has not been - // minted yet. The first push goes out when that token registers. - logger.debug("[LiveActivity] jobs activity started locally — awaiting update token") - } else { - scheduleJobsActivityStart() - } - } - - /// Schedule the push-to-start after a short delay. A foregrounded device - /// starts the activity itself (no push-to-start budget) and reports it - /// within a second or two, cancelling this task. Only a backgrounded - /// device ends up actually receiving the push-to-start. - private func scheduleJobsActivityStart() { - guard pendingStartTask == nil else { return } - logger.info("[LiveActivity] scheduling jobs activity push-to-start in 5s") - pendingStartTask = Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled, let self else { return } - self.pendingStartTask = nil - guard !self.trackedJobs.isEmpty else { return } - guard !self.hasAnyActivityToken, !self.jobsActivityLocallyStarted else { - self.logger.info("[LiveActivity] push-to-start skipped — a device already has the activity") - return - } - self.sendJobsActivityStart() - } - } - - /// Cancel a pending push-to-start — the activity already exists. - private func cancelJobsActivityStart() { - if pendingStartTask != nil { - pendingStartTask?.cancel() - pendingStartTask = nil - logger.debug("[LiveActivity] pending push-to-start cancelled") - } - } - - /// Push a `start` for the aggregate activity to every device with a - /// push-to-start token. - private func sendJobsActivityStart() { - let devices = pairedDevices.filter { ($0.liveActivityStartToken?.isEmpty == false) } - guard !devices.isEmpty else { - logger.warning("[LiveActivity] start skipped — no paired device has a push-to-start token (pairedDevices=\(self.pairedDevices.count, privacy: .public))") - return - } - guard let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[LiveActivity] start skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") - return - } - let now = Date() - let staleAfter: TimeInterval = allJobsDone ? 8 * 3600 : 3600 - let payload: [String: Any] = ["aps": [ - "timestamp": Int(now.timeIntervalSince1970), - "event": "start", - "content-state": jobsContentStateDict(at: now), - "attributes-type": "RxCodeJobActivityAttributes", - "attributes": [String: Any](), - "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), - ]] - lastPushedJobsSignature = jobsSignature - logger.info("[LiveActivity] start jobs activity devices=\(devices.count, privacy: .public) jobs=\(self.trackedJobs.count, privacy: .public)") - for device in devices { - guard let token = device.liveActivityStartToken else { continue } - logger.info("[LiveActivity] start → posting push startTokenPrefix=\(String(token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - Task { - await postRawPush(deviceToken: token, pushType: "liveactivity", - apnsPayload: payload, collapseID: nil, device: device, pushURL: pushURL) - } - } - } - - /// Push an `update` for the aggregate activity. `staleAfter` sets when the - /// activity dims — long for the terminal all-done state, which stays on - /// screen until the user dismisses it. No `end` is ever sent. - private func sendJobsActivityUpdate(staleAfter: TimeInterval) { - let now = Date() - let payload: [String: Any] = ["aps": [ - "timestamp": Int(now.timeIntervalSince1970), - "event": "update", - "content-state": jobsContentStateDict(at: now), - "stale-date": Int(now.addingTimeInterval(staleAfter).timeIntervalSince1970), - ]] - pushToActivityTokens(payload: payload) - } - - /// Push an `end` event that clears an orphaned aggregate activity — one - /// left running by a previous desktop session that crashed or quit - /// mid-job and never delivered the finishing update. `dismissal-date` is - /// set to now so iOS removes it promptly instead of letting it linger on - /// screen for the system's default window. - private func sendJobsActivityEnd(deviceToken: String, device: PairedDevice) { - guard let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[LiveActivity] end skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") - return - } - let now = Date() - let payload: [String: Any] = ["aps": [ - "timestamp": Int(now.timeIntervalSince1970), - "event": "end", - "content-state": ["jobs": [[String: Any]](), "updatedAt": now.timeIntervalSince1970], - "dismissal-date": Int(now.timeIntervalSince1970), - ]] - logger.info("[LiveActivity] end → posting push tokenPrefix=\(String(deviceToken.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - Task { - await postRawPush(deviceToken: deviceToken, pushType: "liveactivity", - apnsPayload: payload, collapseID: "rxcode-jobs-activity", - device: device, pushURL: pushURL) - } - } - - /// Build the ActivityKit `content-state` dict. Field names mirror - /// `RxCodeJobActivityAttributes.ContentState` in the widget target. - private func jobsContentStateDict(at date: Date) -> [String: Any] { - let jobs: [[String: Any]] = trackedJobs.map { job in - var dict: [String: Any] = [ - "id": job.sessionID, - "phase": job.isDone ? "done" : "running", - "title": job.title, - "projectName": job.projectName, - "todoDone": job.todoDone, - "todoTotal": job.todoTotal, - ] - if let step = job.currentStep, !step.isEmpty { - dict["currentStep"] = step - } - return dict - } - return ["jobs": jobs, "updatedAt": date.timeIntervalSince1970] - } - - /// Push a Live Activity payload to every registered aggregate-activity - /// token (one per paired device). - private func pushToActivityTokens(payload: [String: Any]) { - guard let pushURL = Self.pushEndpointURL(from: relayURL) else { - logger.error("[LiveActivity] update skipped — cannot derive push endpoint from relay \(self.relayURL.absoluteString, privacy: .public)") - return - } - var matched = 0 - for device in pairedDevices { - for ref in (device.liveActivityTokens ?? []) { - matched += 1 - logger.info("[LiveActivity] push → activity token activity=\(ref.activityID, privacy: .public) tokenPrefix=\(String(ref.token.prefix(12)), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - Task { - await postRawPush(deviceToken: ref.token, pushType: "liveactivity", - apnsPayload: payload, collapseID: "rxcode-jobs-activity", - device: device, pushURL: pushURL) - } - } - } - if matched == 0 { - logger.warning("[LiveActivity] update has no registered activity token — the mobile never reported one") - } - } - - private func pushWidgetUpdateIfJobCountChanged() { - guard streamingSessionIDs.count != lastWidgetJobCount else { return } - pushWidgetUpdate() - } - - /// Push the current ongoing-job count and agent usage to every paired - /// device as a silent background notification, refreshing the home-screen - /// widget. Also called by `AppState` when rate-limit usage refreshes. - func pushWidgetUpdate() { - let jobCount = streamingSessionIDs.count - lastWidgetJobCount = jobCount - let devices = pairedDevices.filter { ($0.apnsToken?.isEmpty == false) } - guard !devices.isEmpty, let pushURL = Self.pushEndpointURL(from: relayURL) else { return } - let usage = usageSnapshotProvider?() - var widget: [String: Any] = [ - "jobs": jobCount, - "updatedAt": Date().timeIntervalSince1970, - ] - if let cc = usage?.cc { widget["cc"] = cc } - if let codex = usage?.codex { widget["codex"] = codex } - let payload: [String: Any] = ["aps": ["content-available": 1], "widget": widget] - for device in devices { - guard let token = device.apnsToken else { continue } - Task { - await postRawPush(deviceToken: token, pushType: "background", - apnsPayload: payload, collapseID: "rxcode-widget", - device: device, pushURL: pushURL) - } - } - } - - /// POST a raw (Live Activity or background) push to the relay `/push` - /// endpoint. Failures are logged and swallowed — these are best-effort. - private func postRawPush( - deviceToken: String, - pushType: String, - apnsPayload: [String: Any], - collapseID: String?, - device: PairedDevice, - pushURL: URL - ) async { - var bodyDict: [String: Any] = [ - "device_token": deviceToken, - "push_type": pushType, - "apns_payload": apnsPayload, - ] - if let collapseID { bodyDict["collapse_id"] = collapseID } - do { - let httpBody = try JSONSerialization.data(withJSONObject: bodyDict) - var request = URLRequest(url: pushURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = httpBody - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { return } - guard (200..<300).contains(http.statusCode) else { - logger.error("[Push] \(pushType, privacy: .public) relay rejected status=\(http.statusCode, privacy: .public) body=\(Self.responseBodyString(data), privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - return - } - if let pushResponse = try? JSONDecoder().decode(APNsPushResponse.self, from: data) { - if (200..<300).contains(pushResponse.statusCode) { - logger.info("[Push] \(pushType, privacy: .public) accepted apnsStatus=\(pushResponse.statusCode, privacy: .public) apnsID=\(pushResponse.apnsID ?? "", privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - } else { - logger.error("[Push] \(pushType, privacy: .public) apns rejected status=\(pushResponse.statusCode, privacy: .public) reason=\(pushResponse.reason, privacy: .public) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - } - } else { - logger.info("[Push] \(pushType, privacy: .public) relay accepted httpStatus=\(http.statusCode, privacy: .public) (no APNs detail in response) deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public)") - } - } catch { - logger.error("[Push] \(pushType, privacy: .public) failed deviceKey=\(String(device.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - // MARK: - Event dispatch - - private func handle(event: RelayClient.Event) { - switch event { - case .stateChanged(let s): - connectionState = s - logger.info("[MobileSync] relay state=\(String(describing: s), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") - case .deliveryFailed(let toHex): - // Drop-on-offline policy — desktop ignores, mobile will resync on - // next reconnect. - logger.warning("[MobileSync] relay delivery failed to mobileKey=\(String(toHex.prefix(12)), privacy: .public)") - case .inbound(let inbound): - handleInbound(inbound) - } - } - - private func handleInbound(_ inbound: RelayClient.Inbound) { - switch inbound.payload { - case .pairRequest(let req): - pendingPairing = req - Task { try? await client.addPeer(req.mobilePubkeyHex) } - case .unpair: - guard isPairedPeer(inbound.fromHex) else { return } - handleRemoteUnpair(pubkeyHex: inbound.fromHex) - case .apnsToken(let t): - if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) { - logger.info("[APNs] token received mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") - pairedDevices[idx].apnsToken = t.tokenHex - pairedDevices[idx].apnsEnvironment = t.environment - pairedDevices[idx].lastSeen = .now - for staleIdx in pairedDevices.indices where staleIdx != idx && pairedDevices[staleIdx].apnsToken == t.tokenHex { - logger.warning("[APNs] clearing duplicate token from stale mobileKey=\(String(self.pairedDevices[staleIdx].pubkeyHex.prefix(12)), privacy: .public) currentMobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public)") - pairedDevices[staleIdx].apnsToken = nil - pairedDevices[staleIdx].apnsEnvironment = nil - } - savePairedDevices() - } else { - logger.warning("[APNs] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)") - } - case .liveActivityToken(let t): - guard let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) else { - logger.warning("[LiveActivity] token received for unknown mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - return - } - if let startToken = t.pushToStartTokenHex, !startToken.isEmpty { - pairedDevices[idx].liveActivityStartToken = startToken - logger.info("[LiveActivity] push-to-start token registered mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - } - if t.activityDismissed == true { - // The user swiped the aggregate Live Activity away. Forget - // this device's update token; once no device tracks the - // activity the next job push-to-starts a fresh one. - if var refs = pairedDevices[idx].liveActivityTokens { - refs.removeAll { t.activityID == nil || $0.activityID == t.activityID } - pairedDevices[idx].liveActivityTokens = refs.isEmpty ? nil : refs - } - if !hasAnyActivityToken { - jobsActivityLocallyStarted = false - lastPushedJobsSignature = "" - cancelJobsActivityStart() - } - logger.info("[LiveActivity] aggregate activity dismissed by user activity=\(t.activityID ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - } else if t.activityStartedLocally == true { - // A foregrounded device started the activity itself with - // `Activity.request` and reported it the instant it was - // created — long before APNs mints the update token. Cancel - // the deferred push-to-start so iOS never spawns a duplicate. - jobsActivityLocallyStarted = true - cancelJobsActivityStart() - logger.info("[LiveActivity] device started aggregate activity locally mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) — deferred push-to-start cancelled") - } else if let activityToken = t.activityTokenHex, !activityToken.isEmpty, - let activityID = t.activityID { - cancelJobsActivityStart() - if trackedJobs.isEmpty { - // This (fresh) desktop process tracks no jobs, yet the - // device still has a job activity registered — it is an - // orphan left behind by a previous desktop session that - // crashed or quit mid-job and never delivered the - // finishing update. End it so it doesn't sit stuck on - // "Working" forever, and drop the now-dead token. - logger.info("[LiveActivity] activity token from device with no tracked jobs — ending orphaned activity activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - sendJobsActivityEnd(deviceToken: activityToken, device: pairedDevices[idx]) - pairedDevices[idx].liveActivityTokens = nil - } else { - // One aggregate activity per device — replace any prior token. - pairedDevices[idx].liveActivityTokens = [ - LiveActivityTokenRef(activityID: activityID, sessionID: "", token: activityToken) - ] - logger.info("[LiveActivity] aggregate activity token registered activity=\(activityID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - // Push the latest known state straight away so a freshly - // started activity isn't left blank until the next change. - lastPushedJobsSignature = jobsSignature - sendJobsActivityUpdate(staleAfter: allJobsDone ? 8 * 3600 : 3600) - } - } - pairedDevices[idx].lastSeen = .now - savePairedDevices() - case .requestSnapshot(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "request_snapshot") else { return } - logger.info("[MobileSync] snapshot requested by mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) activeSession=\(req.activeSessionID ?? "", privacy: .public)") - // AppState owns the data; it observes pendingSnapshotRequests - // and replies. Stub for now — wired up by AppState bridge. - var userInfo: [String: Any] = ["from": inbound.fromHex] - userInfo["activeSessionID"] = req.activeSessionID - NotificationCenter.default.post( - name: .mobileSyncSnapshotRequested, - object: nil, - userInfo: userInfo - ) - case .settingsUpdate(let update): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "settings_update") else { return } - NotificationCenter.default.post( - name: .mobileSyncSettingsUpdateReceived, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": update] - ) - case .userMessage(let msg): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "user_message") else { return } - NotificationCenter.default.post( - name: .mobileSyncUserMessageReceived, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": msg] - ) - case .cancelStream(let cancel): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "cancel_stream") else { return } - NotificationCenter.default.post( - name: .mobileSyncCancelStreamRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": cancel] - ) - case .removeQueuedMessage(let payload): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "remove_queued_message") else { return } - NotificationCenter.default.post( - name: .mobileSyncRemoveQueuedRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": payload] - ) - case .newSessionRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "new_session_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncNewSessionRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .threadActionRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_action_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncThreadActionRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .loadMoreMessages(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "load_more_messages") else { return } - NotificationCenter.default.post( - name: .mobileSyncLoadMoreMessagesRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .searchRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "search_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncSearchRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .threadChangesRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "thread_changes_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncThreadChangesRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .subscribeSession(let sub): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "subscribe_session") else { return } - subscribedSessions[inbound.fromHex] = sub.sessionID ?? "" - var userInfo: [String: Any] = ["from": inbound.fromHex] - userInfo["activeSessionID"] = sub.sessionID - NotificationCenter.default.post( - name: .mobileSyncSnapshotRequested, - object: nil, - userInfo: userInfo - ) - case .permissionResponse(let resp): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "permission_response") else { return } - NotificationCenter.default.post( - name: .mobileSyncPermissionResponse, - object: nil, - userInfo: ["payload": resp] - ) - case .questionAnswer(let answer): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "question_answer") else { return } - NotificationCenter.default.post( - name: .mobileSyncQuestionAnswerReceived, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": answer] - ) - case .planDecision(let decision): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "plan_decision") else { return } - NotificationCenter.default.post( - name: .mobileSyncPlanDecisionReceived, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": decision] - ) - case .branchOpRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "branch_op_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncBranchOpRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .folderTreeRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "folder_tree_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncFolderTreeRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .createProjectRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "create_project_request") else { return } - NotificationCenter.default.post( - name: .mobileSyncCreateProjectRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .runProfileMutationRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_mutation_request") else { return } - logger.info("[MobileSync] run profile mutation requested operation=\(req.operation.rawValue, privacy: .public) project=\(req.projectID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncRunProfileMutationRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .runProfileRunRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_run_request") else { return } - logger.info("[MobileSync] run profile start requested project=\(req.projectID.uuidString, privacy: .public) profile=\(req.profileID.uuidString, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncRunProfileRunRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .runProfileStopRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "run_profile_stop_request") else { return } - logger.info("[MobileSync] run profile stop requested task=\(req.taskID?.uuidString ?? "", privacy: .public) project=\(req.projectID?.uuidString ?? "", privacy: .public) profile=\(req.profileID?.uuidString ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncRunProfileStopRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .skillCatalogRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_catalog_request") else { return } - logger.info("[MobileSync] skill catalog requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncSkillCatalogRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .skillMutationRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_mutation_request") else { return } - logger.info("[MobileSync] skill mutation requested operation=\(req.operation.rawValue, privacy: .public) plugin=\(req.pluginID, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncSkillMutationRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .skillSourceMutationRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "skill_source_mutation_request") else { return } - logger.info("[MobileSync] skill source mutation requested operation=\(req.operation.rawValue, privacy: .public) source=\(req.sourceID ?? req.gitURL ?? "", privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncSkillSourceMutationRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .acpRegistryRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_registry_request") else { return } - logger.info("[MobileSync] acp registry requested forceRefresh=\(req.forceRefresh, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncACPRegistryRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .acpMutationRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "acp_mutation_request") else { return } - logger.info("[MobileSync] acp mutation requested operation=\(req.operation.rawValue, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncACPMutationRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .mcpConfigRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_config_request") else { return } - logger.info("[MobileSync] mcp config requested mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncMCPConfigRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .mcpMutationRequest(let req): - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "mcp_mutation_request") else { return } - logger.info("[MobileSync] mcp mutation requested operation=\(req.operation.rawValue, privacy: .public) server=\(req.serverName, privacy: .public) mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - NotificationCenter.default.post( - name: .mobileSyncMCPMutationRequested, - object: nil, - userInfo: ["from": inbound.fromHex, "payload": req] - ) - case .ping: - guard acceptPairedOnlyPayload(from: inbound.fromHex, type: "ping") else { return } - Task { try? await client.send(.pong(PongPayload()), toHex: inbound.fromHex) } - default: - break - } - } - - private func isPairedPeer(_ pubkeyHex: String) -> Bool { - pairedDevices.contains { $0.pubkeyHex == pubkeyHex } - } - - private func acceptPairedOnlyPayload(from pubkeyHex: String, type: String) -> Bool { - guard isPairedPeer(pubkeyHex) else { - logger.warning("[MobileSync] rejecting \(type, privacy: .public) from unknown mobileKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") - Task { - try? await client.addPeer(pubkeyHex) - try? await client.send(.unpair(UnpairPayload(reason: "unknown_peer")), toHex: pubkeyHex) - await client.removePeer(pubkeyHex) - } - return false - } - return true - } - - private func handleRemoteUnpair(pubkeyHex: String) { - guard pairedDevices.contains(where: { $0.pubkeyHex == pubkeyHex }) else { - Task { await client.removePeer(pubkeyHex) } - return - } - - pairedDevices.removeAll { $0.pubkeyHex == pubkeyHex } - savePairedDevices() - subscribedSessions.removeValue(forKey: pubkeyHex) - logger.info("[MobileSync] removed paired device after remote unpair") - Task { await client.removePeer(pubkeyHex) } - } - - /// Send a payload to a single peer (used by AppState when replying to - /// `request_snapshot` etc). - func send(_ payload: Payload, toHex hex: String) async { - do { - try await client.send(payload, toHex: hex) - logger.debug("[MobileSync] sent type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public)") - } catch { - logger.error("[MobileSync] send failed type=\(payload.logName, privacy: .public) to mobileKey=\(String(hex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - // MARK: - Persistence - private var pairedDevicesURL: URL { + var pairedDevicesURL: URL { AppSupport.bundleScopedURL.appendingPathComponent("paired_devices.json") } - private func loadPairedDevices() { + func loadPairedDevices() { guard let data = try? Data(contentsOf: pairedDevicesURL) else { return } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 pairedDevices = (try? decoder.decode([PairedDevice].self, from: data)) ?? [] } - private func savePairedDevices() { + func savePairedDevices() { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = .prettyPrinted @@ -1190,7 +486,7 @@ final class MobileSyncService: ObservableObject { } } - private static func pushEndpointURL(from relayURL: URL) -> URL? { + static func pushEndpointURL(from relayURL: URL) -> URL? { guard var components = URLComponents(url: relayURL, resolvingAgainstBaseURL: false) else { return nil } @@ -1220,25 +516,25 @@ final class MobileSyncService: ObservableObject { return components.url } - private static func responseBodyString(_ data: Data) -> String { + static func responseBodyString(_ data: Data) -> String { let raw = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return raw.isEmpty ? "Empty response" : raw } - private static func testNotificationCollapseID(for device: PairedDevice) -> String { + static func testNotificationCollapseID(for device: PairedDevice) -> String { "rxcode-test-\(device.pubkeyHex.prefix(32))" } /// Collapse repeated alerts for the same session + kind so a device shows /// the latest banner instead of stacking one per stream event. - private static func notificationCollapseID(for payload: NotificationPayload, device: PairedDevice) -> String { + static func notificationCollapseID(for payload: NotificationPayload, device: PairedDevice) -> String { let scope = payload.sessionID ?? "global" return "rxcode-\(payload.kind.rawValue)-\(scope.prefix(48))" } } -private struct APNsPushRequest: Codable { +struct APNsPushRequest: Codable { let deviceToken: String let encryptedAlert: String let category: String? @@ -1252,7 +548,7 @@ private struct APNsPushRequest: Codable { } } -private struct APNsPushResponse: Codable { +struct APNsPushResponse: Codable { let statusCode: Int let reason: String let apnsID: String? @@ -1264,28 +560,6 @@ private struct APNsPushResponse: Codable { } } -/// Latest content the desktop knows for one job in the aggregate Live -/// Activity. Stored in `MobileSyncService.trackedJobs` in start order. -private struct JobContent { - var sessionID: String - var title: String - var projectName: String - var todoDone: Int - var todoTotal: Int - var currentStep: String? - /// `true` once the job has finished. It shows the "done" phase but stays - /// in the aggregate list so the activity can report the completed batch. - var isDone: Bool - - /// Identifies a distinct rendered state for one job, so an update only - /// pushes on a real change rather than on every session event. Includes - /// `title` so the activity refreshes when the desktop swaps in an - /// AI-summarized title. - var signature: String { - "\(sessionID)|\(isDone ? "done" : "run")|\(title)|\(todoDone)/\(todoTotal)|\(currentStep ?? "")" - } -} - extension Notification.Name { static let mobileSyncSnapshotRequested = Notification.Name("mobileSync.snapshotRequested") static let mobileSyncUserMessageReceived = Notification.Name("mobileSync.userMessageReceived") diff --git a/RxCode/Views/ChatSettingsTab.swift b/RxCode/Views/ChatSettingsTab.swift new file mode 100644 index 0000000..6d7deb6 --- /dev/null +++ b/RxCode/Views/ChatSettingsTab.swift @@ -0,0 +1,451 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI +import TipKit + +// MARK: - Chat Settings Tab + +struct ChatSettingsTab: View { + @Environment(AppState.self) private var appState + @State private var isRefreshingAgentStatus = false + + var body: some View { + @Bindable var appState = appState + ScrollView { + VStack(alignment: .leading, spacing: 24) { + agentRuntimeSection + Divider() + modelSection + Divider() + summarizationSection + Divider() + permissionModeSection + Divider() + effortSection + Divider() + focusModeSection + Divider() + autoPreviewSection + Divider() + archiveSection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - Agent Runtime Section + + private var agentRuntimeSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Agent Runtimes") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Spacer() + + Button { + Task { + isRefreshingAgentStatus = true + await appState.refreshAgentInstallations() + isRefreshingAgentStatus = false + } + } label: { + if isRefreshingAgentStatus { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.borderless) + .disabled(isRefreshingAgentStatus) + .help("Refresh installation status") + } + + VStack(spacing: 8) { + agentRuntimeRow( + title: "Claude Code", + installed: appState.claudeInstalled, + version: appState.claudeVersion, + path: appState.claudeBinaryPath + ) + agentRuntimeRow( + title: "Codex", + installed: appState.codexInstalled, + version: appState.codexVersion, + path: appState.codexBinaryPath + ) + } + } + } + + private func agentRuntimeRow( + title: String, + installed: Bool, + version: String?, + path: String? + ) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + .font(.system(size: ClaudeTheme.size(14))) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(title) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text(installed ? "Installed" : "Not found") + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) + if let version, !version.isEmpty { + Text(version) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + + Text(path ?? "No executable detected") + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) + .foregroundStyle(path == nil ? .secondary : ClaudeTheme.textPrimary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + // MARK: - Archive Section + + private var archiveSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Archive") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("Inactive chats are moved to the archive automatically. Pinned chats are never auto-archived.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Toggle(isOn: $appState.autoArchiveEnabled) { + Text("Auto-archive inactive chats") + } + .toggleStyle(.switch) + .fixedSize() + + HStack(spacing: 10) { + Text("Archive after") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + Stepper( + value: $appState.archiveRetentionDays, + in: 1...365 + ) { + Text("\(appState.archiveRetentionDays) day\(appState.archiveRetentionDays == 1 ? "" : "s") of inactivity") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + } + .fixedSize() + } + .disabled(!appState.autoArchiveEnabled) + .opacity(appState.autoArchiveEnabled ? 1 : 0.5) + } + } + + // MARK: - Model Section + + private var modelSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Default Model") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("Used for new sessions. You can override the model per session from the toolbar.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Picker("", selection: defaultModelKey) { + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + Section(section.title) { + ForEach(section.models, id: \.key) { model in + Text(model.displayName).tag(model.key) + } + } + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + + Text(selectedDefaultModel.description) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + + private var defaultModelKey: Binding { + Binding( + get: { "\(appState.selectedAgentProvider.rawValue):\(appState.selectedModel)" }, + set: { key in + let parts = key.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, let provider = AgentProvider(rawValue: parts[0]) else { return } + appState.selectedAgentProvider = provider + appState.selectedModel = parts[1] + } + ) + } + + private var selectedDefaultModel: AgentModel { + appState.availableAgentModelSections() + .flatMap(\.models) + .first { $0.provider == appState.selectedAgentProvider && $0.id == appState.selectedModel } + ?? AgentModel( + provider: appState.selectedAgentProvider, + id: appState.selectedModel, + displayName: appState.modelDisplayLabel(appState.selectedModel, provider: appState.selectedAgentProvider), + description: AppState.modelDescription(appState.selectedModel, provider: appState.selectedAgentProvider) + ) + } + + // MARK: - Summarization Section + + private var summarizationSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Summarization Model") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("Used to generate short session titles. The default follows each thread's model.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Picker("Provider", selection: $appState.summarizationProvider) { + ForEach(SummarizationProvider.availableCases) { provider in + Text(provider.displayName).tag(provider) + } + } + .pickerStyle(.menu) + .fixedSize() + .popoverTip(RxCodeTips.SummarizationModelTip(), arrowEdge: .trailing) + .onChange(of: appState.summarizationProvider) { _, newValue in + guard newValue == .openAI, appState.openAISummarizationModels.isEmpty else { return } + Task { await appState.refreshOpenAISummarizationModels() } + } + + switch appState.summarizationProvider { + case .selectedClient: + Text("Uses the model saved on the current thread.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + case .openAI: + openAISummarizationForm + case .appleFoundationModel: + appleFoundationModelStatus + } + } + } + + private var appleFoundationModelStatus: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Runs on-device with Apple Intelligence. Private, free, and offline.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + if let reason = FoundationModelSummarizationService.unavailabilityReason { + Text(reason) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var openAISummarizationForm: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 10) { + settingsTextFieldRow( + label: "Endpoint", + text: $appState.openAISummarizationEndpoint, + prompt: AppState.defaultOpenAISummarizationEndpoint + ) + + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("API Key") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 84, alignment: .leading) + SecureField("sk-...", text: $appState.openAISummarizationAPIKey) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + } + + HStack(spacing: 10) { + Picker("Model", selection: $appState.openAISummarizationModel) { + if !appState.openAISummarizationModel.isEmpty, + !appState.openAISummarizationModels.contains(appState.openAISummarizationModel) + { + Text(appState.openAISummarizationModel).tag(appState.openAISummarizationModel) + } + ForEach(appState.openAISummarizationModels, id: \.self) { model in + Text(model).tag(model) + } + if appState.openAISummarizationModels.isEmpty && appState.openAISummarizationModel.isEmpty { + Text("Fetch models first").tag("") + } + } + .pickerStyle(.menu) + .frame(width: 260, alignment: .leading) + + Button { + Task { await appState.refreshOpenAISummarizationModels() } + } label: { + if appState.isLoadingOpenAISummarizationModels { + ProgressView() + .controlSize(.small) + } else { + Label("Fetch Models", systemImage: "arrow.clockwise") + } + } + .disabled(appState.isLoadingOpenAISummarizationModels) + } + + if let error = appState.openAISummarizationModelsError { + Text(error) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { + guard appState.openAISummarizationModels.isEmpty else { return } + Task { await appState.refreshOpenAISummarizationModels() } + } + } + + private func settingsTextFieldRow(label: String, text: Binding, prompt: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(.secondary) + .frame(width: 84, alignment: .leading) + TextField(prompt, text: text) + .textFieldStyle(.roundedBorder) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + } + } + + // MARK: - Permission Mode Section + + private var permissionModeSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Default Permission Mode") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("Used for new sessions. You can override the permission mode per session from the toolbar.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Picker("", selection: $appState.permissionMode) { + ForEach(PermissionMode.allCases, id: \.self) { mode in + Text(LocalizedStringKey(mode.displayName)).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + + Text(AppState.permissionModeDescription(appState.permissionMode)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + + // MARK: - Effort Section + + private var effortSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Default Effort Level") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("Used for new sessions. You can override the effort level per session from the toolbar.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Picker("", selection: $appState.selectedEffort) { + Text("Auto").tag("auto") + ForEach(AppState.availableEfforts, id: \.self) { effort in + Text(effortDisplayName(effort)).tag(effort) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + + Text(AppState.effortDescription(appState.selectedEffort)) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } + + // MARK: - Focus Mode Section + + private var focusModeSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Focus Mode") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("focus.mode.desc") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + Toggle(isOn: $appState.focusMode) { + Text("Enable Focus Mode") + } + .toggleStyle(.switch) + .fixedSize() + } + } + + // MARK: - Auto-Preview Attachments Section + + private var autoPreviewSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + Text("Auto-preview Attachments") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + + Text("auto.preview.desc") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Toggle("URL links", isOn: $appState.autoPreviewSettings.url) + Toggle("File paths", isOn: $appState.autoPreviewSettings.filePath) + Toggle("Images", isOn: $appState.autoPreviewSettings.image) + Toggle("Long text (200+ characters)", isOn: $appState.autoPreviewSettings.longText) + } + .toggleStyle(.checkbox) + } + } + + private func effortDisplayName(_ effort: String) -> String { + switch effort { + case "low": return "Low" + case "medium": return "Medium" + case "high": return "High" + case "xhigh": return "Extra High" + case "max": return "Max" + default: return effort.capitalized + } + } +} diff --git a/RxCode/Views/ChatToolbarComponents.swift b/RxCode/Views/ChatToolbarComponents.swift new file mode 100644 index 0000000..65e4faf --- /dev/null +++ b/RxCode/Views/ChatToolbarComponents.swift @@ -0,0 +1,362 @@ +import AppKit +import RxCodeChatKit +import RxCodeCore +import SwiftUI +import TipKit +import UniformTypeIdentifiers + +// MARK: - Shared Chat UI Components + +func effortDisplayName(_ effort: String) -> String { + switch effort { + case "low": return "Low" + case "medium": return "Medium" + case "high": return "High" + case "xhigh": return "XHigh" + case "max": return "Max" + default: return effort.capitalized + } +} + +enum ChatToolbarControlsPlacement { + case toolbar + case composer +} + +struct ChatToolbarControls: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + + let placement: ChatToolbarControlsPlacement + + init(placement: ChatToolbarControlsPlacement = .toolbar) { + self.placement = placement + } + + var effectiveMode: PermissionMode { windowState.sessionPermissionMode ?? appState.permissionMode } + var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model } + var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider } + + var body: some View { + HStack(spacing: placement == .composer ? 8 : 4) { + if placement == .composer { + Spacer(minLength: 12) + } + + Menu { + Section("Permission Mode") { + ForEach(PermissionMode.allCases.filter { $0 != .plan }, id: \.self) { mode in + Button { + appState.setSessionPermissionMode(mode, in: windowState) + } label: { + Text(LocalizedStringKey(mode.displayName)) + if effectiveMode == mode { Image(systemName: "checkmark") } + } + } + } + } label: { + controlLabel( + title: effectiveMode.displayName, + icon: nil, + isAccent: placement == .composer, + isActive: false + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Permission mode: \(effectiveMode.displayName)") + .accessibilityIdentifier("permission-mode-menu") + + Menu { + Section("Model Picker") { + 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 + 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.modelDisplayLabel(effectiveModel, provider: effectiveProvider))", + icon: nil, + isAccent: false, + isActive: false + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Model: \(effectiveProvider.displayName) · \(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))") + .accessibilityIdentifier("provider-model-menu") + .popoverTip(RxCodeTips.AgentSelectionTip(), arrowEdge: .top) + + Menu { + Section("Effort Picker") { + Button { + appState.setSessionEffort(nil, in: windowState) + } label: { + Text("Auto Effort") + if windowState.sessionEffort == nil { Image(systemName: "checkmark") } + } + Divider() + ForEach(AppState.availableEfforts, id: \.self) { effort in + Button { + appState.setSessionEffort(effort, in: windowState) + } label: { + Text(LocalizedStringKey(effortDisplayName(effort))) + if windowState.sessionEffort == effort { Image(systemName: "checkmark") } + } + } + } + } label: { + controlLabel( + title: windowState.sessionEffort.map { effortDisplayName($0) } ?? "Auto Effort", + icon: nil, + isAccent: false, + isActive: false + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Effort level: \(windowState.sessionEffort.map { effortDisplayName($0) } ?? "Auto Effort")") + .accessibilityIdentifier("effort-menu") + } + .frame(maxWidth: placement == .composer ? .infinity : nil, alignment: .leading) + } + + @ViewBuilder + func controlLabel(title: String, icon: String?, isAccent: Bool, isActive: Bool) -> some View { + switch placement { + case .toolbar: + ToolbarChipLabel(title: title, icon: icon, isActive: isActive) + case .composer: + ComposerControlLabel(title: title, icon: icon, isAccent: isAccent, isActive: isActive) + } + } +} + +struct ToolbarChipLabel: View { + let title: String + var icon: String? = nil + var isActive: Bool = false + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 4) { + if let icon { + Image(systemName: icon) + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + } + Text(LocalizedStringKey(title)) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + } + .foregroundStyle(isActive ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + isActive + ? ClaudeTheme.accent.opacity(isHovered ? 0.18 : 0.12) + : (isHovered ? ClaudeTheme.surfaceTertiary : ClaudeTheme.surfaceSecondary), + in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder( + isActive ? ClaudeTheme.accent.opacity(0.45) : ClaudeTheme.borderSubtle, + lineWidth: isActive ? 1 : 0.5 + ) + ) + .onHover { isHovered = $0 } + .pointerCursorOnHover() + .animation(.easeInOut(duration: 0.15), value: isHovered) + .animation(.easeInOut(duration: 0.15), value: isActive) + } +} + +struct ComposerControlLabel: View { + let title: String + var icon: String? = nil + let isAccent: Bool + var isActive: Bool = false + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 6) { + if let icon { + Image(systemName: icon) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + } + Text(LocalizedStringKey(title)) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .lineLimit(1) + } + .foregroundStyle((isAccent || isActive) ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background( + isActive + ? ClaudeTheme.accent.opacity(isHovered ? 0.18 : 0.12) + : (isHovered ? ClaudeTheme.surfaceSecondary.opacity(0.85) : Color.clear), + in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder( + isActive ? ClaudeTheme.accent.opacity(0.45) : Color.clear, + lineWidth: isActive ? 1 : 0 + ) + ) + .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .onHover { isHovered = $0 } + .pointerCursorOnHover() + .animation(.easeInOut(duration: 0.12), value: isHovered) + .animation(.easeInOut(duration: 0.15), value: isActive) + } +} + +struct ChatDetailModifiers: ViewModifier { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + @Environment(ChatBridge.self) var chatBridge + + var presentedRequest: PermissionRequest? { + guard let id = windowState.presentedPermissionId else { return nil } + return windowState.pendingPermissions.first { + $0.id == id && $0.sessionId == windowState.currentSessionId + } + } + + var presentedPermissionModalRequest: PermissionRequest? { + guard let request = presentedRequest, request.toolName != "AskUserQuestion" else { return nil } + return request + } + + var questionSheetBinding: Binding { + Binding( + get: { presentedRequest?.toolName == "AskUserQuestion" }, + set: { isPresented in + if !isPresented, presentedRequest?.toolName == "AskUserQuestion" { + windowState.presentedPermissionId = nil + } + } + ) + } + + var presentedPlan: PendingPlan? { + guard let id = windowState.presentedPlanToolCallId else { return nil } + return chatBridge.pendingPlans.first { $0.toolCallId == id } + } + + var planSheetBinding: Binding { + Binding( + get: { presentedPlan != nil }, + set: { isPresented in + if !isPresented { + windowState.presentedPlanToolCallId = nil + } + } + ) + } + + func body(content: Content) -> some View { + content + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: windowState.pendingPermissions.count) + .overlay { + if let request = presentedPermissionModalRequest { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + .onTapGesture { windowState.presentedPermissionId = nil } + PermissionModal(request: request, onClose: { windowState.presentedPermissionId = nil }) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge)) + .shadow(color: ClaudeTheme.shadowColor, radius: 20) + .transition(.scale(scale: 0.95).combined(with: .opacity)) + } + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: windowState.presentedPermissionId) + } + } + .sheet(isPresented: questionSheetBinding) { + if let request = presentedRequest, request.toolName == "AskUserQuestion" { + QuestionSheetView( + request: request, + remainingCount: max(0, windowState.pendingPermissions.count - 1), + onSubmit: { answers in + windowState.submitQuestionAnswersHandler?(request.id, answers) + }, + onClose: { + // Just dismiss — keep the request in the queue so the user + // can re-open it from the banner later. + windowState.presentedPermissionId = nil + }, + onSkipAll: { + windowState.skipQuestionHandler?(request.id) + } + ) + .environment(appState) + .environment(windowState) + } + } + .onChange(of: windowState.pendingPermissions.map(\.id)) { _, newIds in + if let id = windowState.presentedPermissionId, !newIds.contains(id) { + windowState.presentedPermissionId = nil + } + } + .sheet(isPresented: planSheetBinding) { + if let plan = presentedPlan { + PlanSheetView( + plan: plan, + remainingCount: max(0, chatBridge.pendingPlans.filter { !$0.isDecided }.count - 1), + onSubmit: { toolCallId, action in + windowState.planDecisionHandler?(toolCallId, action) + // Decision recorded — close the sheet. The chip in chat + // will reflect the new status once the result lands. + windowState.presentedPlanToolCallId = nil + }, + onClose: { + // Just dismiss — the plan stays in the queue so the user + // can re-open it from the banner or inline chip later. + windowState.presentedPlanToolCallId = nil + } + ) + .environment(appState) + .environment(windowState) + .environment(chatBridge) + } + } + .onChange(of: chatBridge.pendingPlans.map(\.toolCallId)) { _, newIds in + if let id = windowState.presentedPlanToolCallId, !newIds.contains(id) { + windowState.presentedPlanToolCallId = nil + } + } + .sheet(isPresented: Bindable(windowState).showModelPicker) { + ModelPickerSheet() + .environment(appState) + .environment(windowState) + } + .sheet(isPresented: Bindable(windowState).showEffortPicker) { + EffortPickerSheet() + .environment(appState) + .environment(windowState) + } + .sheet(item: Bindable(windowState).interactiveTerminal) { terminal in + InteractiveTerminalPopup(state: terminal) + } + } +} diff --git a/RxCode/Views/Inspector/ChangeSectionViews.swift b/RxCode/Views/Inspector/ChangeSectionViews.swift new file mode 100644 index 0000000..08224b8 --- /dev/null +++ b/RxCode/Views/Inspector/ChangeSectionViews.swift @@ -0,0 +1,351 @@ +import AppKit +import SwiftUI +import RxCodeCore + +// MARK: - ChangeSection + +struct ChangeSection: View { + let title: String + let actionTitle: String + let files: [GitChangeFile] + @Binding var selection: Set + let emptyMessage: String + let isBusy: Bool + let onAction: () async -> Void + let onFocus: () -> Void + let onEmptyTap: () -> Void + + var body: some View { + VStack(spacing: 0) { + header + Group { + if files.isEmpty { + Text(emptyMessage) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(files) { file in + ChangeFileRow( + file: file, + isSelected: selection.contains(file.displayPath), + onPrimarySelect: { + onFocus() + selectOnly(file) + }, + onSecondarySelect: { isCommandPressed in + onFocus() + if isCommandPressed { + toggle(file) + } else { + selectOnly(file) + } + } + ) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + // Tapping the empty area inside a section focuses it and clears + // all selections (both unstaged and staged). Taps on rows are + // intercepted by the row's own gesture, so this only fires on + // the background. + onFocus() + onEmptyTap() + } + } + + @ViewBuilder + private var header: some View { + HStack(spacing: 8) { + Text(LocalizedStringKey(title)) + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text("\(files.count)") + .font(.system(size: ClaudeTheme.size(10), weight: .semibold, design: .monospaced)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ClaudeTheme.surfaceSecondary, in: Capsule()) + Spacer() + Button { + Task { await onAction() } + } label: { + Text(LocalizedStringKey(actionTitle)) + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .padding(.horizontal, 10) + .padding(.vertical, 3) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(selection.isEmpty || isBusy) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(ClaudeTheme.surfaceSecondary.opacity(0.4)) + } + + private func toggle(_ file: GitChangeFile) { + if selection.contains(file.displayPath) { + selection.remove(file.displayPath) + } else { + selection.insert(file.displayPath) + } + } + + private func selectOnly(_ file: GitChangeFile) { + selection = [file.displayPath] + } +} + +// MARK: - ChangeFileRow + +struct ChangeFileRow: View { + let file: GitChangeFile + let isSelected: Bool + let onPrimarySelect: () -> Void + let onSecondarySelect: (_ isCommandPressed: Bool) -> Void + + @Environment(WindowState.self) private var windowState + @State private var isHovering = false + + var body: some View { + HStack(spacing: 8) { + Image(systemName: iconName) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(iconColor) + .frame(width: 14) + + VStack(alignment: .leading, spacing: 1) { + Text(file.name) + .font(.system(size: ClaudeTheme.size(12), weight: .medium, design: .monospaced)) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + if !file.parentDirectory.isEmpty { + Text(file.parentDirectory) + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent.opacity(0.8) : ClaudeTheme.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer(minLength: 4) + + Text(badgeLabel) + .font(.system(size: ClaudeTheme.size(9), weight: .semibold, design: .monospaced)) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : iconColor) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + (isSelected ? ClaudeTheme.textOnAccent.opacity(0.2) : iconColor.opacity(0.15)), + in: Capsule() + ) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(rowFill) + ) + .contentShape(Rectangle()) + .onTapGesture { onPrimarySelect() } + .overlay { + ChangeFileRightClickOverlay( + onRightClick: onSecondarySelect, + onShowDiff: openDiff + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onHover { isHovering = $0 } + .help("Click to select · Command-right-click to add or remove · Right-click for Show Diff") + } + + private func openDiff() { + windowState.diffFile = PreviewFile( + path: file.path, + name: file.name, + gitDiffMode: file.diffMode + ) + } + + private var rowFill: Color { + if isSelected { return ClaudeTheme.accent } + if isHovering { return ClaudeTheme.surfaceSecondary } + return .clear + } + + private var iconName: String { + if file.isUntracked { return "doc.badge.plus" } + switch file.statusChar { + case "A": return "plus.circle" + case "D": return "minus.circle" + case "R": return "arrow.right.circle" + case "C": return "doc.on.doc" + case "U": return "exclamationmark.triangle" + default: return "pencil" + } + } + + private var iconColor: Color { + if file.isUntracked { return ClaudeTheme.statusSuccess } + switch file.statusChar { + case "A": return ClaudeTheme.statusSuccess + case "D": return ClaudeTheme.statusError + case "U": return ClaudeTheme.statusError + default: return ClaudeTheme.statusWarning + } + } + + private var badgeLabel: String { + if file.isUntracked { return "?" } + return String(file.statusChar) + } +} + +struct ChangeFileRightClickOverlay: NSViewRepresentable { + let onRightClick: (_ isCommandPressed: Bool) -> Void + let onShowDiff: () -> Void + + func makeNSView(context: Context) -> RightClickSelectionView { + let view = RightClickSelectionView() + view.onRightClick = onRightClick + view.onShowDiff = onShowDiff + return view + } + + func updateNSView(_ nsView: RightClickSelectionView, context: Context) { + nsView.onRightClick = onRightClick + nsView.onShowDiff = onShowDiff + } +} + +final class RightClickSelectionView: NSView { + var onRightClick: ((_ isCommandPressed: Bool) -> Void)? + var onShowDiff: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let event = window?.currentEvent, + event.type == .rightMouseDown else { + return nil + } + return self + } + + override func rightMouseDown(with event: NSEvent) { + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + onRightClick?(modifiers.contains(.command)) + showMenu(for: event) + } + + private func showMenu(for event: NSEvent) { + let menu = NSMenu() + let showDiffItem = NSMenuItem(title: "Show Diff", action: #selector(showDiff), keyEquivalent: "") + showDiffItem.target = self + menu.addItem(showDiffItem) + menu.popUp(positioning: showDiffItem, at: convert(event.locationInWindow, from: nil), in: self) + } + + @objc private func showDiff() { + onShowDiff?() + } +} + +// MARK: - Loading + +struct GitChangeFile: Identifiable, Hashable { + let id = UUID() + let path: String // absolute path + let displayPath: String // path relative to repo + let statusChar: Character + let isUntracked: Bool + let diffMode: PreviewFile.GitDiffMode + + var name: String { + (displayPath as NSString).lastPathComponent + } + + var parentDirectory: String { + (displayPath as NSString).deletingLastPathComponent + } +} + +struct GitChangesSnapshot { + let unstaged: [GitChangeFile] + let staged: [GitChangeFile] +} + +func loadAllChangedFiles(at projectPath: String) async -> GitChangesSnapshot { + guard let raw = await GitHelper.run(["status", "--porcelain=v1", "-z"], at: projectPath) else { + return GitChangesSnapshot(unstaged: [], staged: []) + } + return parsePorcelainZ(raw, projectPath: projectPath) +} + +/// Parses `git status --porcelain=v1 -z` output into both unstaged and staged +/// file lists. Entries are NUL-terminated; renames have an extra NUL-terminated +/// "old path" record. +func parsePorcelainZ(_ raw: String, projectPath: String) -> GitChangesSnapshot { + var unstaged: [GitChangeFile] = [] + var staged: [GitChangeFile] = [] + let tokens = raw.split(separator: "\0", omittingEmptySubsequences: false).map(String.init) + var i = 0 + while i < tokens.count { + let entry = tokens[i] + i += 1 + guard entry.count >= 3 else { continue } + let chars = Array(entry) + let indexChar = chars[0] + let worktreeChar = chars[1] + let displayPath = String(entry.dropFirst(3)) + + let isUntracked = (indexChar == "?" && worktreeChar == "?") + let isRename = indexChar == "R" || worktreeChar == "R" + if isRename, i < tokens.count { + i += 1 + } + + let absolute = (projectPath as NSString).appendingPathComponent(displayPath) + + // Unstaged includes untracked files and any worktree-side change. + if isUntracked || (worktreeChar != " " && worktreeChar != "?") { + let statusChar: Character = isUntracked ? "?" : worktreeChar + let diffMode: PreviewFile.GitDiffMode = isUntracked ? .untracked : .unstaged + unstaged.append(GitChangeFile( + path: absolute, + displayPath: displayPath, + statusChar: statusChar, + isUntracked: isUntracked, + diffMode: diffMode + )) + } + + // Staged: anything with an index-side change other than '?'. + if !isUntracked, indexChar != " " { + staged.append(GitChangeFile( + path: absolute, + displayPath: displayPath, + statusChar: indexChar, + isUntracked: false, + diffMode: .staged + )) + } + } + let sort: ([GitChangeFile]) -> [GitChangeFile] = { files in + files.sorted { $0.displayPath.localizedStandardCompare($1.displayPath) == .orderedAscending } + } + return GitChangesSnapshot(unstaged: sort(unstaged), staged: sort(staged)) +} diff --git a/RxCode/Views/Inspector/ChangesView.swift b/RxCode/Views/Inspector/ChangesView.swift new file mode 100644 index 0000000..0754859 --- /dev/null +++ b/RxCode/Views/Inspector/ChangesView.swift @@ -0,0 +1,440 @@ +import AppKit +import SwiftUI +import RxCodeCore + +// MARK: - Changes (combined Unstaged + Staged + commit composer) + +/// Combined view that lists unstaged files on top and staged files at the +/// bottom, with multi-select Stage/Unstage actions and a commit composer at +/// the bottom. Generates commit messages via the configured summarization +/// provider. +enum ChangeSectionFocus: Hashable { + case unstaged + case staged +} + +struct ChangesView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + @State private var unstaged: [GitChangeFile] = [] + @State private var staged: [GitChangeFile] = [] + @State private var selectedUnstaged: Set = [] + @State private var selectedStaged: Set = [] + @State private var commitMessage: String = "" + @State private var isLoading = true + @State private var isBusy = false + @State private var isGenerating = false + @State private var isPushing = false + @State private var errorMessage: String? + @State private var upstream: GitHelper.UpstreamStatus? + @State private var headWatcher: (any DispatchSourceFileSystemObject)? + @State private var indexWatcher: (any DispatchSourceFileSystemObject)? + @State private var refreshTask: Task? + @FocusState private var focusedSection: ChangeSectionFocus? + + var body: some View { + if let project = windowState.selectedProject { + content(projectPath: project.path) + .task(id: project.path) { await refresh(at: project.path) } + .onChange(of: appState.isStreaming(in: windowState)) { old, new in + if old && !new { triggerRefresh(at: project.path) } + } + .onAppear { startWatching(at: project.path) } + .onDisappear { stopWatching() } + .onChange(of: project.path) { _, newPath in + Task { @MainActor in + stopWatching() + startWatching(at: newPath) + } + } + } else { + InspectorEmptyState( + title: "No project selected", + message: "Select a project to review changes." + ) + } + } + + @ViewBuilder + private func content(projectPath: String) -> some View { + VStack(spacing: 0) { + ChangeSection( + title: "Unstaged", + actionTitle: "Stage", + files: unstaged, + selection: $selectedUnstaged, + emptyMessage: "Working tree matches the index.", + isBusy: isBusy, + onAction: { await stageSelected(at: projectPath) }, + onFocus: { focusedSection = .unstaged }, + onEmptyTap: clearAllSelections + ) + .focusable() + .focused($focusedSection, equals: .unstaged) + + Divider() + + ChangeSection( + title: "Staged", + actionTitle: "Unstage", + files: staged, + selection: $selectedStaged, + emptyMessage: "Nothing in the index.", + isBusy: isBusy, + onAction: { await unstageSelected(at: projectPath) }, + onFocus: { focusedSection = .staged }, + onEmptyTap: clearAllSelections + ) + .focusable() + .focused($focusedSection, equals: .staged) + + Divider() + + commitComposer(projectPath: projectPath) + } + .overlay(alignment: .top) { + if isLoading && unstaged.isEmpty && staged.isEmpty { + ProgressView().controlSize(.small).padding(.top, 20) + } + } + .background(selectAllShortcut) + } + + /// Cmd+A hooks up to whichever section is currently focused. Hidden button + /// avoids stealing focus from the visible UI. + @ViewBuilder + private var selectAllShortcut: some View { + Button("Select All in Section") { + selectAllInFocusedSection() + } + .keyboardShortcut("a", modifiers: .command) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + + private func clearAllSelections() { + selectedUnstaged.removeAll() + selectedStaged.removeAll() + } + + private func selectAllInFocusedSection() { + switch focusedSection { + case .unstaged: + selectedUnstaged = Set(unstaged.map { $0.displayPath }) + case .staged: + selectedStaged = Set(staged.map { $0.displayPath }) + case .none: + // No section focused — default to whichever has files, preferring + // unstaged. Keeps Cmd+A useful right after the view appears. + if !unstaged.isEmpty { + selectedUnstaged = Set(unstaged.map { $0.displayPath }) + focusedSection = .unstaged + } else if !staged.isEmpty { + selectedStaged = Set(staged.map { $0.displayPath }) + focusedSection = .staged + } + } + } + + // MARK: - Commit composer + + @ViewBuilder + private func commitComposer(projectPath: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Commit message") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + Button { + Task { await generateMessage(at: projectPath) } + } label: { + HStack(spacing: 4) { + if isGenerating { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "sparkles") + } + Text("Generate") + } + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + } + .buttonStyle(.borderless) + .disabled(isGenerating || staged.isEmpty) + .help("Generate a commit message from the staged diff") + } + + TextEditor(text: $commitMessage) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + .scrollContentBackground(.hidden) + .frame(minHeight: 60, maxHeight: 120) + .padding(6) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfaceSecondary.opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(ClaudeTheme.borderSubtle.opacity(0.6), lineWidth: 0.5) + ) + + if let errorMessage { + Text(errorMessage) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + .lineLimit(3) + } + + HStack(spacing: 8) { + upstreamStatusLabel + Spacer() + Button { + Task { await push(at: projectPath) } + } label: { + HStack(spacing: 4) { + if isPushing { + ProgressView().controlSize(.mini) + } else { + Image(systemName: pushIconName) + } + Text(pushLabel) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + .buttonStyle(.bordered) + .disabled(isBusy || isPushing || !canPush) + .help(pushHelp) + + Button { + Task { await commit(at: projectPath) } + } label: { + HStack(spacing: 4) { + if isBusy { + ProgressView().controlSize(.mini) + } + Text("Commit \(staged.count > 0 ? "(\(staged.count))" : "")") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .tint(ClaudeTheme.accent) + .disabled(isBusy || staged.isEmpty || commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + @ViewBuilder + private var upstreamStatusLabel: some View { + if let upstream { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: ClaudeTheme.size(10))) + Text(upstream.branch) + .font(.system(size: ClaudeTheme.size(11), weight: .medium, design: .monospaced)) + Text("·") + .foregroundStyle(ClaudeTheme.textTertiary) + Text(upstreamSummary(upstream)) + .font(.system(size: ClaudeTheme.size(11))) + } + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } + } + + private func upstreamSummary(_ status: GitHelper.UpstreamStatus) -> String { + if status.upstream == nil { + return status.remotes.isEmpty ? "no remote" : "no upstream" + } + switch (status.ahead, status.behind) { + case (0, 0): return "up to date" + case (let a, 0): return "↑\(a)" + case (0, let b): return "↓\(b)" + case (let a, let b): return "↑\(a) ↓\(b)" + } + } + + private var canPush: Bool { + guard let upstream else { return false } + if upstream.upstream == nil { + // Need at least one remote to create an upstream. + return !upstream.remotes.isEmpty + } + return upstream.ahead > 0 + } + + private var pushLabel: String { + guard let upstream else { return "Push" } + return upstream.upstream == nil ? "Publish" : "Push" + } + + private var pushIconName: String { + guard let upstream else { return "arrow.up" } + return upstream.upstream == nil ? "icloud.and.arrow.up" : "arrow.up" + } + + private var pushHelp: String { + guard let upstream else { return "Push the current branch" } + if upstream.upstream == nil { + if upstream.remotes.isEmpty { + return "No remote configured. Add one with `git remote add origin `." + } + let remote = upstream.remotes.contains("origin") ? "origin" : (upstream.remotes.first ?? "origin") + return "Push and track \(remote)/\(upstream.branch)" + } + if upstream.ahead == 0 { + return "Nothing to push — local matches \(upstream.upstream ?? "upstream")" + } + return "Push \(upstream.ahead) commit\(upstream.ahead == 1 ? "" : "s") to \(upstream.upstream ?? "upstream")" + } + + // MARK: - Actions + + private func stageSelected(at projectPath: String) async { + let paths = selectedUnstaged.sorted() + guard !paths.isEmpty else { return } + isBusy = true + errorMessage = nil + let err = await GitHelper.stage(paths: paths, at: projectPath) + isBusy = false + if let err { + errorMessage = err + } else { + selectedUnstaged.removeAll() + await refresh(at: projectPath) + } + } + + private func unstageSelected(at projectPath: String) async { + let paths = selectedStaged.sorted() + guard !paths.isEmpty else { return } + isBusy = true + errorMessage = nil + let err = await GitHelper.unstage(paths: paths, at: projectPath) + isBusy = false + if let err { + errorMessage = err + } else { + selectedStaged.removeAll() + await refresh(at: projectPath) + } + } + + private func commit(at projectPath: String) async { + isBusy = true + errorMessage = nil + let err = await GitHelper.commit(message: commitMessage, at: projectPath) + isBusy = false + if let err { + errorMessage = err + } else { + commitMessage = "" + await refresh(at: projectPath) + } + } + + private func push(at projectPath: String) async { + guard let upstream else { return } + isPushing = true + errorMessage = nil + let setUpstream = upstream.upstream == nil + let remote = upstream.remotes.contains("origin") ? "origin" : (upstream.remotes.first ?? "origin") + let err = await GitHelper.push( + at: projectPath, + remote: remote, + branch: upstream.branch, + setUpstream: setUpstream + ) + isPushing = false + if let err { + errorMessage = err + } else { + await refresh(at: projectPath) + } + } + + private func generateMessage(at projectPath: String) async { + guard !staged.isEmpty else { return } + isGenerating = true + errorMessage = nil + async let diffTask = GitHelper.stagedDiff(at: projectPath) + async let statTask = GitHelper.stagedStat(at: projectPath) + let (diff, stat) = await (diffTask, statTask) + let fileSummary = staged.map { "\($0.statusChar) \($0.displayPath)" }.joined(separator: "\n") + let result = await appState.generateCommitMessage( + diff: diff, + stat: stat, + fileSummary: fileSummary + ) + isGenerating = false + if let result, !result.isEmpty { + commitMessage = result + } else { + errorMessage = "Commit message generation is unavailable. Configure a summarization provider in Settings." + } + } + + // MARK: - Refresh + watching + + private func triggerRefresh(at projectPath: String) { + refreshTask?.cancel() + refreshTask = Task { await refresh(at: projectPath) } + } + + private func refresh(at projectPath: String) async { + isLoading = true + async let filesTask = Task.detached(priority: .userInitiated) { + await loadAllChangedFiles(at: projectPath) + }.value + async let upstreamTask = Task.detached(priority: .userInitiated) { + await GitHelper.upstreamStatus(at: projectPath) + }.value + let (result, upstreamResult) = await (filesTask, upstreamTask) + guard !Task.isCancelled else { return } + unstaged = result.unstaged + staged = result.staged + upstream = upstreamResult + // Drop selections for files that no longer appear. + let unstagedPaths = Set(unstaged.map { $0.displayPath }) + let stagedPaths = Set(staged.map { $0.displayPath }) + selectedUnstaged.formIntersection(unstagedPaths) + selectedStaged.formIntersection(stagedPaths) + isLoading = false + } + + private func startWatching(at projectPath: String) { + headWatcher = makeWatcher(path: projectPath + "/.git/HEAD", projectPath: projectPath) + indexWatcher = makeWatcher(path: projectPath + "/.git/index", projectPath: projectPath) + } + + private func stopWatching() { + headWatcher?.cancel() + indexWatcher?.cancel() + headWatcher = nil + indexWatcher = nil + } + + private func makeWatcher(path: String, projectPath: String) -> (any DispatchSourceFileSystemObject)? { + let fd = open(path, O_EVTONLY) + guard fd != -1 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .attrib], + queue: .main + ) + source.setEventHandler { + triggerRefresh(at: projectPath) + } + source.setCancelHandler { close(fd) } + source.resume() + return source + } +} diff --git a/RxCode/Views/Inspector/RightInspectorHeaderControls.swift b/RxCode/Views/Inspector/RightInspectorHeaderControls.swift new file mode 100644 index 0000000..5a5a30b --- /dev/null +++ b/RxCode/Views/Inspector/RightInspectorHeaderControls.swift @@ -0,0 +1,187 @@ +import AppKit +import SwiftUI +import RxCodeCore + +// MARK: - ModeSwitchControl + +struct ModeSwitchControl: View { + @Binding var selection: InspectorMode + + var body: some View { + HStack(spacing: 0) { + ForEach(InspectorMode.allCases, id: \.self) { mode in + let isSelected = selection == mode + Button { + withAnimation(.easeInOut(duration: 0.18)) { selection = mode } + } label: { + Text(LocalizedStringKey(mode.rawValue)) + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : ClaudeTheme.textTertiary) + .background( + ZStack { + if isSelected { + RoundedRectangle(cornerRadius: 7) + .fill(ClaudeTheme.accent) + .shadow(color: ClaudeTheme.accent.opacity(0.25), radius: 3, x: 0, y: 1) + } + } + ) + } + .buttonStyle(.plain) + } + } + .padding(2) + .background(ClaudeTheme.surfaceSecondary.opacity(0.7), in: RoundedRectangle(cornerRadius: 9)) + } +} + +// MARK: - HeaderPickerLabel + +/// Shared chevron-down dropdown label used by both Review and Inspector mode. +struct HeaderPickerLabel: View { + let icon: String? + let title: String + @State private var isHovered = false + + var body: some View { + HStack(spacing: 5) { + if let icon { + Image(systemName: icon) + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + } + Text(LocalizedStringKey(title)) + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + Image(systemName: "chevron.down") + .font(.system(size: ClaudeTheme.size(9), weight: .bold)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.leading, 1) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + (isHovered ? ClaudeTheme.surfaceSecondary : Color.clear), + in: RoundedRectangle(cornerRadius: 7) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(ClaudeTheme.borderSubtle.opacity(isHovered ? 0.6 : 0.0), lineWidth: 0.5) + ) + .onHover { isHovered = $0 } + .animation(.easeInOut(duration: 0.12), value: isHovered) + } +} + +// MARK: - ReviewTabPicker + +struct ReviewTabPicker: View { + @Binding var selection: InspectorReviewTab + + var body: some View { + Menu { + ForEach(InspectorReviewTab.allCases, id: \.self) { tab in + Button { + selection = tab + } label: { + HStack { + Text(LocalizedStringKey(tab.rawValue)) + if selection == tab { Image(systemName: "checkmark") } + } + } + } + } label: { + HeaderPickerLabel(icon: nil, title: selection.rawValue) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } +} + +// MARK: - InspectorTabPicker + +struct InspectorTabPicker: View { + @Binding var selection: InspectorTab + var onTabClick: (InspectorTab) -> Void = { _ in } + + var body: some View { + Menu { + ForEach(InspectorTab.allCases, id: \.self) { tab in + Button { + selection = tab + onTabClick(tab) + } label: { + HStack { + Image(systemName: tab.icon) + Text(LocalizedStringKey(tab.rawValue)) + if selection == tab { Image(systemName: "checkmark") } + } + } + } + } label: { + HeaderPickerLabel(icon: selection.icon, title: selection.rawValue) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } +} + +// MARK: - HeaderIconButton + +struct HeaderIconButton: View { + let systemImage: String + let help: String + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(isHovered ? ClaudeTheme.textPrimary : ClaudeTheme.textTertiary) + .frame(width: 22, height: 22) + .background( + isHovered ? ClaudeTheme.surfaceSecondary : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + .help(help) + .onHover { isHovered = $0 } + .animation(.easeInOut(duration: 0.12), value: isHovered) + } +} + +// MARK: - Empty State Helper + +struct InspectorEmptyState: View { + let title: String + let message: String + + var body: some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: "doc.badge.plus") + .font(.system(size: ClaudeTheme.size(32))) + .foregroundStyle(ClaudeTheme.textTertiary) + Text(title) + .font(.system(size: ClaudeTheme.size(14), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + Text(message) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index c334645..d0840a4 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -270,1103 +270,3 @@ struct RightInspectorPanel: View { } } } - -// MARK: - ModeSwitchControl - -private struct ModeSwitchControl: View { - @Binding var selection: InspectorMode - - var body: some View { - HStack(spacing: 0) { - ForEach(InspectorMode.allCases, id: \.self) { mode in - let isSelected = selection == mode - Button { - withAnimation(.easeInOut(duration: 0.18)) { selection = mode } - } label: { - Text(LocalizedStringKey(mode.rawValue)) - .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contentShape(Rectangle()) - .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : ClaudeTheme.textTertiary) - .background( - ZStack { - if isSelected { - RoundedRectangle(cornerRadius: 7) - .fill(ClaudeTheme.accent) - .shadow(color: ClaudeTheme.accent.opacity(0.25), radius: 3, x: 0, y: 1) - } - } - ) - } - .buttonStyle(.plain) - } - } - .padding(2) - .background(ClaudeTheme.surfaceSecondary.opacity(0.7), in: RoundedRectangle(cornerRadius: 9)) - } -} - -// MARK: - HeaderPickerLabel - -/// Shared chevron-down dropdown label used by both Review and Inspector mode. -private struct HeaderPickerLabel: View { - let icon: String? - let title: String - @State private var isHovered = false - - var body: some View { - HStack(spacing: 5) { - if let icon { - Image(systemName: icon) - .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textSecondary) - } - Text(LocalizedStringKey(title)) - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textPrimary) - Image(systemName: "chevron.down") - .font(.system(size: ClaudeTheme.size(9), weight: .bold)) - .foregroundStyle(ClaudeTheme.textTertiary) - .padding(.leading, 1) - } - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background( - (isHovered ? ClaudeTheme.surfaceSecondary : Color.clear), - in: RoundedRectangle(cornerRadius: 7) - ) - .overlay( - RoundedRectangle(cornerRadius: 7) - .strokeBorder(ClaudeTheme.borderSubtle.opacity(isHovered ? 0.6 : 0.0), lineWidth: 0.5) - ) - .onHover { isHovered = $0 } - .animation(.easeInOut(duration: 0.12), value: isHovered) - } -} - -// MARK: - ReviewTabPicker - -private struct ReviewTabPicker: View { - @Binding var selection: InspectorReviewTab - - var body: some View { - Menu { - ForEach(InspectorReviewTab.allCases, id: \.self) { tab in - Button { - selection = tab - } label: { - HStack { - Text(LocalizedStringKey(tab.rawValue)) - if selection == tab { Image(systemName: "checkmark") } - } - } - } - } label: { - HeaderPickerLabel(icon: nil, title: selection.rawValue) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - } -} - -// MARK: - InspectorTabPicker - -private struct InspectorTabPicker: View { - @Binding var selection: InspectorTab - var onTabClick: (InspectorTab) -> Void = { _ in } - - var body: some View { - Menu { - ForEach(InspectorTab.allCases, id: \.self) { tab in - Button { - selection = tab - onTabClick(tab) - } label: { - HStack { - Image(systemName: tab.icon) - Text(LocalizedStringKey(tab.rawValue)) - if selection == tab { Image(systemName: "checkmark") } - } - } - } - } label: { - HeaderPickerLabel(icon: selection.icon, title: selection.rawValue) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - } -} - -// MARK: - HeaderIconButton - -private struct HeaderIconButton: View { - let systemImage: String - let help: String - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - Button(action: action) { - Image(systemName: systemImage) - .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) - .foregroundStyle(isHovered ? ClaudeTheme.textPrimary : ClaudeTheme.textTertiary) - .frame(width: 22, height: 22) - .background( - isHovered ? ClaudeTheme.surfaceSecondary : Color.clear, - in: RoundedRectangle(cornerRadius: 6) - ) - } - .buttonStyle(.plain) - .help(help) - .onHover { isHovered = $0 } - .animation(.easeInOut(duration: 0.12), value: isHovered) - } -} - -// MARK: - Empty State Helper - -struct InspectorEmptyState: View { - let title: String - let message: String - - var body: some View { - VStack(spacing: 8) { - Spacer() - Image(systemName: "doc.badge.plus") - .font(.system(size: ClaudeTheme.size(32))) - .foregroundStyle(ClaudeTheme.textTertiary) - Text(title) - .font(.system(size: ClaudeTheme.size(14), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textSecondary) - Text(message) - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(ClaudeTheme.textTertiary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - This Thread - -struct ThisThreadDiffView: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - private var summaries: [FileEditSummary] { - _ = appState.threadFileEditsRevision - return appState.threadFileEdits(in: windowState) - } - - var body: some View { - let summaries = summaries - if summaries.isEmpty { - InspectorEmptyState( - title: "No file changes yet", - message: "This thread has not edited any files." - ) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(summaries) { summary in - ThisThreadFileRow(summary: summary) - } - } - .padding(12) - } - } - } -} - -private struct ThisThreadFileRow: View { - let summary: FileEditSummary - @Environment(WindowState.self) private var windowState - @State private var isHovering = false - - private var additions: Int { - summary.hunks.reduce(0) { count, hunk in - count + nonEmptyLineCount(hunk.newString) - } - } - - private var deletions: Int { - summary.hunks.reduce(0) { count, hunk in - count + nonEmptyLineCount(hunk.oldString) - } - } - - private var parentDirectory: String { - let parent = (summary.path as NSString).deletingLastPathComponent - return parent - } - - var body: some View { - Button { - windowState.diffFile = PreviewFile( - path: summary.path, - name: summary.name, - editHunks: summary.hunks - ) - } label: { - HStack(spacing: 8) { - Image(systemName: summary.containsWrite ? "doc.badge.plus" : "pencil") - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(ClaudeTheme.statusWarning) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 1) { - Text(summary.name) - .font(.system(size: ClaudeTheme.size(13), weight: .medium, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - if !parentDirectory.isEmpty { - Text(parentDirectory) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - .truncationMode(.middle) - } - } - - Spacer(minLength: 6) - - HStack(spacing: 6) { - if additions > 0 { - Text("+\(additions)") - .font(.system(size: ClaudeTheme.size(11), weight: .semibold, design: .monospaced)) - .foregroundStyle(ClaudeTheme.statusSuccess) - } - if deletions > 0 { - Text("−\(deletions)") - .font(.system(size: ClaudeTheme.size(11), weight: .semibold, design: .monospaced)) - .foregroundStyle(ClaudeTheme.statusError) - } - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(isHovering ? ClaudeTheme.surfaceSecondary : Color.clear) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } - - private func nonEmptyLineCount(_ s: String) -> Int { - guard !s.isEmpty else { return 0 } - return s.components(separatedBy: "\n").count - } -} - -struct BranchInfoView: View { - @Environment(WindowState.self) private var windowState - - var body: some View { - if let project = windowState.selectedProject { - VStack(spacing: 0) { - GitStatusView(projectPath: project.path) - Spacer() - } - } else { - InspectorEmptyState(title: "No project selected", message: "Select a project to inspect its branch.") - } - } -} - -// MARK: - Changes (combined Unstaged + Staged + commit composer) - -/// Combined view that lists unstaged files on top and staged files at the -/// bottom, with multi-select Stage/Unstage actions and a commit composer at -/// the bottom. Generates commit messages via the configured summarization -/// provider. -enum ChangeSectionFocus: Hashable { - case unstaged - case staged -} - -struct ChangesView: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - @State private var unstaged: [GitChangeFile] = [] - @State private var staged: [GitChangeFile] = [] - @State private var selectedUnstaged: Set = [] - @State private var selectedStaged: Set = [] - @State private var commitMessage: String = "" - @State private var isLoading = true - @State private var isBusy = false - @State private var isGenerating = false - @State private var isPushing = false - @State private var errorMessage: String? - @State private var upstream: GitHelper.UpstreamStatus? - @State private var headWatcher: (any DispatchSourceFileSystemObject)? - @State private var indexWatcher: (any DispatchSourceFileSystemObject)? - @State private var refreshTask: Task? - @FocusState private var focusedSection: ChangeSectionFocus? - - var body: some View { - if let project = windowState.selectedProject { - content(projectPath: project.path) - .task(id: project.path) { await refresh(at: project.path) } - .onChange(of: appState.isStreaming(in: windowState)) { old, new in - if old && !new { triggerRefresh(at: project.path) } - } - .onAppear { startWatching(at: project.path) } - .onDisappear { stopWatching() } - .onChange(of: project.path) { _, newPath in - Task { @MainActor in - stopWatching() - startWatching(at: newPath) - } - } - } else { - InspectorEmptyState( - title: "No project selected", - message: "Select a project to review changes." - ) - } - } - - @ViewBuilder - private func content(projectPath: String) -> some View { - VStack(spacing: 0) { - ChangeSection( - title: "Unstaged", - actionTitle: "Stage", - files: unstaged, - selection: $selectedUnstaged, - emptyMessage: "Working tree matches the index.", - isBusy: isBusy, - onAction: { await stageSelected(at: projectPath) }, - onFocus: { focusedSection = .unstaged }, - onEmptyTap: clearAllSelections - ) - .focusable() - .focused($focusedSection, equals: .unstaged) - - Divider() - - ChangeSection( - title: "Staged", - actionTitle: "Unstage", - files: staged, - selection: $selectedStaged, - emptyMessage: "Nothing in the index.", - isBusy: isBusy, - onAction: { await unstageSelected(at: projectPath) }, - onFocus: { focusedSection = .staged }, - onEmptyTap: clearAllSelections - ) - .focusable() - .focused($focusedSection, equals: .staged) - - Divider() - - commitComposer(projectPath: projectPath) - } - .overlay(alignment: .top) { - if isLoading && unstaged.isEmpty && staged.isEmpty { - ProgressView().controlSize(.small).padding(.top, 20) - } - } - .background(selectAllShortcut) - } - - /// Cmd+A hooks up to whichever section is currently focused. Hidden button - /// avoids stealing focus from the visible UI. - @ViewBuilder - private var selectAllShortcut: some View { - Button("Select All in Section") { - selectAllInFocusedSection() - } - .keyboardShortcut("a", modifiers: .command) - .frame(width: 0, height: 0) - .opacity(0) - .accessibilityHidden(true) - } - - private func clearAllSelections() { - selectedUnstaged.removeAll() - selectedStaged.removeAll() - } - - private func selectAllInFocusedSection() { - switch focusedSection { - case .unstaged: - selectedUnstaged = Set(unstaged.map { $0.displayPath }) - case .staged: - selectedStaged = Set(staged.map { $0.displayPath }) - case .none: - // No section focused — default to whichever has files, preferring - // unstaged. Keeps Cmd+A useful right after the view appears. - if !unstaged.isEmpty { - selectedUnstaged = Set(unstaged.map { $0.displayPath }) - focusedSection = .unstaged - } else if !staged.isEmpty { - selectedStaged = Set(staged.map { $0.displayPath }) - focusedSection = .staged - } - } - } - - // MARK: - Commit composer - - @ViewBuilder - private func commitComposer(projectPath: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text("Commit message") - .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textTertiary) - Spacer() - Button { - Task { await generateMessage(at: projectPath) } - } label: { - HStack(spacing: 4) { - if isGenerating { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "sparkles") - } - Text("Generate") - } - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - } - .buttonStyle(.borderless) - .disabled(isGenerating || staged.isEmpty) - .help("Generate a commit message from the staged diff") - } - - TextEditor(text: $commitMessage) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) - .scrollContentBackground(.hidden) - .frame(minHeight: 60, maxHeight: 120) - .padding(6) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(ClaudeTheme.surfaceSecondary.opacity(0.5)) - ) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .strokeBorder(ClaudeTheme.borderSubtle.opacity(0.6), lineWidth: 0.5) - ) - - if let errorMessage { - Text(errorMessage) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.statusError) - .lineLimit(3) - } - - HStack(spacing: 8) { - upstreamStatusLabel - Spacer() - Button { - Task { await push(at: projectPath) } - } label: { - HStack(spacing: 4) { - if isPushing { - ProgressView().controlSize(.mini) - } else { - Image(systemName: pushIconName) - } - Text(pushLabel) - .font(.system(size: ClaudeTheme.size(12), weight: .medium)) - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - } - .buttonStyle(.bordered) - .disabled(isBusy || isPushing || !canPush) - .help(pushHelp) - - Button { - Task { await commit(at: projectPath) } - } label: { - HStack(spacing: 4) { - if isBusy { - ProgressView().controlSize(.mini) - } - Text("Commit \(staged.count > 0 ? "(\(staged.count))" : "")") - .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - } - .buttonStyle(.borderedProminent) - .tint(ClaudeTheme.accent) - .disabled(isBusy || staged.isEmpty || commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - } - - @ViewBuilder - private var upstreamStatusLabel: some View { - if let upstream { - HStack(spacing: 4) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: ClaudeTheme.size(10))) - Text(upstream.branch) - .font(.system(size: ClaudeTheme.size(11), weight: .medium, design: .monospaced)) - Text("·") - .foregroundStyle(ClaudeTheme.textTertiary) - Text(upstreamSummary(upstream)) - .font(.system(size: ClaudeTheme.size(11))) - } - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - .truncationMode(.tail) - } - } - - private func upstreamSummary(_ status: GitHelper.UpstreamStatus) -> String { - if status.upstream == nil { - return status.remotes.isEmpty ? "no remote" : "no upstream" - } - switch (status.ahead, status.behind) { - case (0, 0): return "up to date" - case (let a, 0): return "↑\(a)" - case (0, let b): return "↓\(b)" - case (let a, let b): return "↑\(a) ↓\(b)" - } - } - - private var canPush: Bool { - guard let upstream else { return false } - if upstream.upstream == nil { - // Need at least one remote to create an upstream. - return !upstream.remotes.isEmpty - } - return upstream.ahead > 0 - } - - private var pushLabel: String { - guard let upstream else { return "Push" } - return upstream.upstream == nil ? "Publish" : "Push" - } - - private var pushIconName: String { - guard let upstream else { return "arrow.up" } - return upstream.upstream == nil ? "icloud.and.arrow.up" : "arrow.up" - } - - private var pushHelp: String { - guard let upstream else { return "Push the current branch" } - if upstream.upstream == nil { - if upstream.remotes.isEmpty { - return "No remote configured. Add one with `git remote add origin `." - } - let remote = upstream.remotes.contains("origin") ? "origin" : (upstream.remotes.first ?? "origin") - return "Push and track \(remote)/\(upstream.branch)" - } - if upstream.ahead == 0 { - return "Nothing to push — local matches \(upstream.upstream ?? "upstream")" - } - return "Push \(upstream.ahead) commit\(upstream.ahead == 1 ? "" : "s") to \(upstream.upstream ?? "upstream")" - } - - // MARK: - Actions - - private func stageSelected(at projectPath: String) async { - let paths = selectedUnstaged.sorted() - guard !paths.isEmpty else { return } - isBusy = true - errorMessage = nil - let err = await GitHelper.stage(paths: paths, at: projectPath) - isBusy = false - if let err { - errorMessage = err - } else { - selectedUnstaged.removeAll() - await refresh(at: projectPath) - } - } - - private func unstageSelected(at projectPath: String) async { - let paths = selectedStaged.sorted() - guard !paths.isEmpty else { return } - isBusy = true - errorMessage = nil - let err = await GitHelper.unstage(paths: paths, at: projectPath) - isBusy = false - if let err { - errorMessage = err - } else { - selectedStaged.removeAll() - await refresh(at: projectPath) - } - } - - private func commit(at projectPath: String) async { - isBusy = true - errorMessage = nil - let err = await GitHelper.commit(message: commitMessage, at: projectPath) - isBusy = false - if let err { - errorMessage = err - } else { - commitMessage = "" - await refresh(at: projectPath) - } - } - - private func push(at projectPath: String) async { - guard let upstream else { return } - isPushing = true - errorMessage = nil - let setUpstream = upstream.upstream == nil - let remote = upstream.remotes.contains("origin") ? "origin" : (upstream.remotes.first ?? "origin") - let err = await GitHelper.push( - at: projectPath, - remote: remote, - branch: upstream.branch, - setUpstream: setUpstream - ) - isPushing = false - if let err { - errorMessage = err - } else { - await refresh(at: projectPath) - } - } - - private func generateMessage(at projectPath: String) async { - guard !staged.isEmpty else { return } - isGenerating = true - errorMessage = nil - async let diffTask = GitHelper.stagedDiff(at: projectPath) - async let statTask = GitHelper.stagedStat(at: projectPath) - let (diff, stat) = await (diffTask, statTask) - let fileSummary = staged.map { "\($0.statusChar) \($0.displayPath)" }.joined(separator: "\n") - let result = await appState.generateCommitMessage( - diff: diff, - stat: stat, - fileSummary: fileSummary - ) - isGenerating = false - if let result, !result.isEmpty { - commitMessage = result - } else { - errorMessage = "Commit message generation is unavailable. Configure a summarization provider in Settings." - } - } - - // MARK: - Refresh + watching - - private func triggerRefresh(at projectPath: String) { - refreshTask?.cancel() - refreshTask = Task { await refresh(at: projectPath) } - } - - private func refresh(at projectPath: String) async { - isLoading = true - async let filesTask = Task.detached(priority: .userInitiated) { - await loadAllChangedFiles(at: projectPath) - }.value - async let upstreamTask = Task.detached(priority: .userInitiated) { - await GitHelper.upstreamStatus(at: projectPath) - }.value - let (result, upstreamResult) = await (filesTask, upstreamTask) - guard !Task.isCancelled else { return } - unstaged = result.unstaged - staged = result.staged - upstream = upstreamResult - // Drop selections for files that no longer appear. - let unstagedPaths = Set(unstaged.map { $0.displayPath }) - let stagedPaths = Set(staged.map { $0.displayPath }) - selectedUnstaged.formIntersection(unstagedPaths) - selectedStaged.formIntersection(stagedPaths) - isLoading = false - } - - private func startWatching(at projectPath: String) { - headWatcher = makeWatcher(path: projectPath + "/.git/HEAD", projectPath: projectPath) - indexWatcher = makeWatcher(path: projectPath + "/.git/index", projectPath: projectPath) - } - - private func stopWatching() { - headWatcher?.cancel() - indexWatcher?.cancel() - headWatcher = nil - indexWatcher = nil - } - - private func makeWatcher(path: String, projectPath: String) -> (any DispatchSourceFileSystemObject)? { - let fd = open(path, O_EVTONLY) - guard fd != -1 else { return nil } - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .delete, .rename, .attrib], - queue: .main - ) - source.setEventHandler { - triggerRefresh(at: projectPath) - } - source.setCancelHandler { close(fd) } - source.resume() - return source - } -} - -// MARK: - ChangeSection - -private struct ChangeSection: View { - let title: String - let actionTitle: String - let files: [GitChangeFile] - @Binding var selection: Set - let emptyMessage: String - let isBusy: Bool - let onAction: () async -> Void - let onFocus: () -> Void - let onEmptyTap: () -> Void - - var body: some View { - VStack(spacing: 0) { - header - Group { - if files.isEmpty { - Text(emptyMessage) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(files) { file in - ChangeFileRow( - file: file, - isSelected: selection.contains(file.displayPath), - onPrimarySelect: { - onFocus() - selectOnly(file) - }, - onSecondarySelect: { isCommandPressed in - onFocus() - if isCommandPressed { - toggle(file) - } else { - selectOnly(file) - } - } - ) - } - } - .padding(.horizontal, 6) - .padding(.vertical, 6) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - .onTapGesture { - // Tapping the empty area inside a section focuses it and clears - // all selections (both unstaged and staged). Taps on rows are - // intercepted by the row's own gesture, so this only fires on - // the background. - onFocus() - onEmptyTap() - } - } - - @ViewBuilder - private var header: some View { - HStack(spacing: 8) { - Text(LocalizedStringKey(title)) - .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) - .foregroundStyle(ClaudeTheme.textPrimary) - Text("\(files.count)") - .font(.system(size: ClaudeTheme.size(10), weight: .semibold, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textTertiary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(ClaudeTheme.surfaceSecondary, in: Capsule()) - Spacer() - Button { - Task { await onAction() } - } label: { - Text(LocalizedStringKey(actionTitle)) - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .padding(.horizontal, 10) - .padding(.vertical, 3) - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(selection.isEmpty || isBusy) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(ClaudeTheme.surfaceSecondary.opacity(0.4)) - } - - private func toggle(_ file: GitChangeFile) { - if selection.contains(file.displayPath) { - selection.remove(file.displayPath) - } else { - selection.insert(file.displayPath) - } - } - - private func selectOnly(_ file: GitChangeFile) { - selection = [file.displayPath] - } -} - -// MARK: - ChangeFileRow - -private struct ChangeFileRow: View { - let file: GitChangeFile - let isSelected: Bool - let onPrimarySelect: () -> Void - let onSecondarySelect: (_ isCommandPressed: Bool) -> Void - - @Environment(WindowState.self) private var windowState - @State private var isHovering = false - - var body: some View { - HStack(spacing: 8) { - Image(systemName: iconName) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(iconColor) - .frame(width: 14) - - VStack(alignment: .leading, spacing: 1) { - Text(file.name) - .font(.system(size: ClaudeTheme.size(12), weight: .medium, design: .monospaced)) - .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : ClaudeTheme.textPrimary) - .lineLimit(1) - .truncationMode(.middle) - if !file.parentDirectory.isEmpty { - Text(file.parentDirectory) - .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent.opacity(0.8) : ClaudeTheme.textTertiary) - .lineLimit(1) - .truncationMode(.middle) - } - } - - Spacer(minLength: 4) - - Text(badgeLabel) - .font(.system(size: ClaudeTheme.size(9), weight: .semibold, design: .monospaced)) - .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : iconColor) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - (isSelected ? ClaudeTheme.textOnAccent.opacity(0.2) : iconColor.opacity(0.15)), - in: Capsule() - ) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(rowFill) - ) - .contentShape(Rectangle()) - .onTapGesture { onPrimarySelect() } - .overlay { - ChangeFileRightClickOverlay( - onRightClick: onSecondarySelect, - onShowDiff: openDiff - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .onHover { isHovering = $0 } - .help("Click to select · Command-right-click to add or remove · Right-click for Show Diff") - } - - private func openDiff() { - windowState.diffFile = PreviewFile( - path: file.path, - name: file.name, - gitDiffMode: file.diffMode - ) - } - - private var rowFill: Color { - if isSelected { return ClaudeTheme.accent } - if isHovering { return ClaudeTheme.surfaceSecondary } - return .clear - } - - private var iconName: String { - if file.isUntracked { return "doc.badge.plus" } - switch file.statusChar { - case "A": return "plus.circle" - case "D": return "minus.circle" - case "R": return "arrow.right.circle" - case "C": return "doc.on.doc" - case "U": return "exclamationmark.triangle" - default: return "pencil" - } - } - - private var iconColor: Color { - if file.isUntracked { return ClaudeTheme.statusSuccess } - switch file.statusChar { - case "A": return ClaudeTheme.statusSuccess - case "D": return ClaudeTheme.statusError - case "U": return ClaudeTheme.statusError - default: return ClaudeTheme.statusWarning - } - } - - private var badgeLabel: String { - if file.isUntracked { return "?" } - return String(file.statusChar) - } -} - -private struct ChangeFileRightClickOverlay: NSViewRepresentable { - let onRightClick: (_ isCommandPressed: Bool) -> Void - let onShowDiff: () -> Void - - func makeNSView(context: Context) -> RightClickSelectionView { - let view = RightClickSelectionView() - view.onRightClick = onRightClick - view.onShowDiff = onShowDiff - return view - } - - func updateNSView(_ nsView: RightClickSelectionView, context: Context) { - nsView.onRightClick = onRightClick - nsView.onShowDiff = onShowDiff - } -} - -private final class RightClickSelectionView: NSView { - var onRightClick: ((_ isCommandPressed: Bool) -> Void)? - var onShowDiff: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - guard let event = window?.currentEvent, - event.type == .rightMouseDown else { - return nil - } - return self - } - - override func rightMouseDown(with event: NSEvent) { - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - onRightClick?(modifiers.contains(.command)) - showMenu(for: event) - } - - private func showMenu(for event: NSEvent) { - let menu = NSMenu() - let showDiffItem = NSMenuItem(title: "Show Diff", action: #selector(showDiff), keyEquivalent: "") - showDiffItem.target = self - menu.addItem(showDiffItem) - menu.popUp(positioning: showDiffItem, at: convert(event.locationInWindow, from: nil), in: self) - } - - @objc private func showDiff() { - onShowDiff?() - } -} - -// MARK: - Loading - -struct GitChangeFile: Identifiable, Hashable { - let id = UUID() - let path: String // absolute path - let displayPath: String // path relative to repo - let statusChar: Character - let isUntracked: Bool - let diffMode: PreviewFile.GitDiffMode - - var name: String { - (displayPath as NSString).lastPathComponent - } - - var parentDirectory: String { - (displayPath as NSString).deletingLastPathComponent - } -} - -private struct GitChangesSnapshot { - let unstaged: [GitChangeFile] - let staged: [GitChangeFile] -} - -private func loadAllChangedFiles(at projectPath: String) async -> GitChangesSnapshot { - guard let raw = await GitHelper.run(["status", "--porcelain=v1", "-z"], at: projectPath) else { - return GitChangesSnapshot(unstaged: [], staged: []) - } - return parsePorcelainZ(raw, projectPath: projectPath) -} - -/// Parses `git status --porcelain=v1 -z` output into both unstaged and staged -/// file lists. Entries are NUL-terminated; renames have an extra NUL-terminated -/// "old path" record. -private func parsePorcelainZ(_ raw: String, projectPath: String) -> GitChangesSnapshot { - var unstaged: [GitChangeFile] = [] - var staged: [GitChangeFile] = [] - let tokens = raw.split(separator: "\0", omittingEmptySubsequences: false).map(String.init) - var i = 0 - while i < tokens.count { - let entry = tokens[i] - i += 1 - guard entry.count >= 3 else { continue } - let chars = Array(entry) - let indexChar = chars[0] - let worktreeChar = chars[1] - let displayPath = String(entry.dropFirst(3)) - - let isUntracked = (indexChar == "?" && worktreeChar == "?") - let isRename = indexChar == "R" || worktreeChar == "R" - if isRename, i < tokens.count { - i += 1 - } - - let absolute = (projectPath as NSString).appendingPathComponent(displayPath) - - // Unstaged includes untracked files and any worktree-side change. - if isUntracked || (worktreeChar != " " && worktreeChar != "?") { - let statusChar: Character = isUntracked ? "?" : worktreeChar - let diffMode: PreviewFile.GitDiffMode = isUntracked ? .untracked : .unstaged - unstaged.append(GitChangeFile( - path: absolute, - displayPath: displayPath, - statusChar: statusChar, - isUntracked: isUntracked, - diffMode: diffMode - )) - } - - // Staged: anything with an index-side change other than '?'. - if !isUntracked, indexChar != " " { - staged.append(GitChangeFile( - path: absolute, - displayPath: displayPath, - statusChar: indexChar, - isUntracked: false, - diffMode: .staged - )) - } - } - let sort: ([GitChangeFile]) -> [GitChangeFile] = { files in - files.sorted { $0.displayPath.localizedStandardCompare($1.displayPath) == .orderedAscending } - } - return GitChangesSnapshot(unstaged: sort(unstaged), staged: sort(staged)) -} diff --git a/RxCode/Views/Inspector/ThisThreadDiffView.swift b/RxCode/Views/Inspector/ThisThreadDiffView.swift new file mode 100644 index 0000000..5d74c81 --- /dev/null +++ b/RxCode/Views/Inspector/ThisThreadDiffView.swift @@ -0,0 +1,134 @@ +import AppKit +import SwiftUI +import RxCodeCore + +// MARK: - This Thread + +struct ThisThreadDiffView: View { + @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState + + private var summaries: [FileEditSummary] { + _ = appState.threadFileEditsRevision + return appState.threadFileEdits(in: windowState) + } + + var body: some View { + let summaries = summaries + if summaries.isEmpty { + InspectorEmptyState( + title: "No file changes yet", + message: "This thread has not edited any files." + ) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(summaries) { summary in + ThisThreadFileRow(summary: summary) + } + } + .padding(12) + } + } + } +} + +struct ThisThreadFileRow: View { + let summary: FileEditSummary + @Environment(WindowState.self) private var windowState + @State private var isHovering = false + + private var additions: Int { + summary.hunks.reduce(0) { count, hunk in + count + nonEmptyLineCount(hunk.newString) + } + } + + private var deletions: Int { + summary.hunks.reduce(0) { count, hunk in + count + nonEmptyLineCount(hunk.oldString) + } + } + + private var parentDirectory: String { + let parent = (summary.path as NSString).deletingLastPathComponent + return parent + } + + var body: some View { + Button { + windowState.diffFile = PreviewFile( + path: summary.path, + name: summary.name, + editHunks: summary.hunks + ) + } label: { + HStack(spacing: 8) { + Image(systemName: summary.containsWrite ? "doc.badge.plus" : "pencil") + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.statusWarning) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 1) { + Text(summary.name) + .font(.system(size: ClaudeTheme.size(13), weight: .medium, design: .monospaced)) + .foregroundStyle(ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + if !parentDirectory.isEmpty { + Text(parentDirectory) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer(minLength: 6) + + HStack(spacing: 6) { + if additions > 0 { + Text("+\(additions)") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold, design: .monospaced)) + .foregroundStyle(ClaudeTheme.statusSuccess) + } + if deletions > 0 { + Text("−\(deletions)") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold, design: .monospaced)) + .foregroundStyle(ClaudeTheme.statusError) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(isHovering ? ClaudeTheme.surfaceSecondary : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + } + + private func nonEmptyLineCount(_ s: String) -> Int { + guard !s.isEmpty else { return 0 } + return s.components(separatedBy: "\n").count + } +} + +struct BranchInfoView: View { + @Environment(WindowState.self) private var windowState + + var body: some View { + if let project = windowState.selectedProject { + VStack(spacing: 0) { + GitStatusView(projectPath: project.path) + Spacer() + } + } else { + InspectorEmptyState(title: "No project selected", message: "Select a project to inspect its branch.") + } + } +} diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index f068ab8..10e793f 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -491,543 +491,6 @@ struct SidebarTabShortcuts: View { } } -// MARK: - Shared Chat UI Components - -private func effortDisplayName(_ effort: String) -> String { - switch effort { - case "low": return "Low" - case "medium": return "Medium" - case "high": return "High" - case "xhigh": return "XHigh" - case "max": return "Max" - default: return effort.capitalized - } -} - -enum ChatToolbarControlsPlacement { - case toolbar - case composer -} - -struct ChatToolbarControls: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - - let placement: ChatToolbarControlsPlacement - - init(placement: ChatToolbarControlsPlacement = .toolbar) { - self.placement = placement - } - - private var effectiveMode: PermissionMode { windowState.sessionPermissionMode ?? appState.permissionMode } - private var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model } - private var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider } - - var body: some View { - HStack(spacing: placement == .composer ? 8 : 4) { - if placement == .composer { - Spacer(minLength: 12) - } - - Menu { - Section("Permission Mode") { - ForEach(PermissionMode.allCases.filter { $0 != .plan }, id: \.self) { mode in - Button { - appState.setSessionPermissionMode(mode, in: windowState) - } label: { - Text(LocalizedStringKey(mode.displayName)) - if effectiveMode == mode { Image(systemName: "checkmark") } - } - } - } - } label: { - controlLabel( - title: effectiveMode.displayName, - icon: nil, - isAccent: placement == .composer, - isActive: false - ) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Permission mode: \(effectiveMode.displayName)") - .accessibilityIdentifier("permission-mode-menu") - - Menu { - Section("Model Picker") { - 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 - 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.modelDisplayLabel(effectiveModel, provider: effectiveProvider))", - icon: nil, - isAccent: false, - isActive: false - ) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Model: \(effectiveProvider.displayName) · \(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))") - .accessibilityIdentifier("provider-model-menu") - .popoverTip(RxCodeTips.AgentSelectionTip(), arrowEdge: .top) - - Menu { - Section("Effort Picker") { - Button { - appState.setSessionEffort(nil, in: windowState) - } label: { - Text("Auto Effort") - if windowState.sessionEffort == nil { Image(systemName: "checkmark") } - } - Divider() - ForEach(AppState.availableEfforts, id: \.self) { effort in - Button { - appState.setSessionEffort(effort, in: windowState) - } label: { - Text(LocalizedStringKey(effortDisplayName(effort))) - if windowState.sessionEffort == effort { Image(systemName: "checkmark") } - } - } - } - } label: { - controlLabel( - title: windowState.sessionEffort.map { effortDisplayName($0) } ?? "Auto Effort", - icon: nil, - isAccent: false, - isActive: false - ) - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Effort level: \(windowState.sessionEffort.map { effortDisplayName($0) } ?? "Auto Effort")") - .accessibilityIdentifier("effort-menu") - } - .frame(maxWidth: placement == .composer ? .infinity : nil, alignment: .leading) - } - - @ViewBuilder - private func controlLabel(title: String, icon: String?, isAccent: Bool, isActive: Bool) -> some View { - switch placement { - case .toolbar: - ToolbarChipLabel(title: title, icon: icon, isActive: isActive) - case .composer: - ComposerControlLabel(title: title, icon: icon, isAccent: isAccent, isActive: isActive) - } - } -} - -struct ToolbarChipLabel: View { - let title: String - var icon: String? = nil - var isActive: Bool = false - - @State private var isHovered = false - - var body: some View { - HStack(spacing: 4) { - if let icon { - Image(systemName: icon) - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - } - Text(LocalizedStringKey(title)) - .font(.system(size: ClaudeTheme.size(12), weight: .medium)) - } - .foregroundStyle(isActive ? ClaudeTheme.accent : ClaudeTheme.textSecondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - isActive - ? ClaudeTheme.accent.opacity(isHovered ? 0.18 : 0.12) - : (isHovered ? ClaudeTheme.surfaceTertiary : ClaudeTheme.surfaceSecondary), - in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - ) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .strokeBorder( - isActive ? ClaudeTheme.accent.opacity(0.45) : ClaudeTheme.borderSubtle, - lineWidth: isActive ? 1 : 0.5 - ) - ) - .onHover { isHovered = $0 } - .pointerCursorOnHover() - .animation(.easeInOut(duration: 0.15), value: isHovered) - .animation(.easeInOut(duration: 0.15), value: isActive) - } -} - -struct ComposerControlLabel: View { - let title: String - var icon: String? = nil - let isAccent: Bool - var isActive: Bool = false - - @State private var isHovered = false - - var body: some View { - HStack(spacing: 6) { - if let icon { - Image(systemName: icon) - .font(.system(size: ClaudeTheme.size(12), weight: .medium)) - } - Text(LocalizedStringKey(title)) - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - .lineLimit(1) - } - .foregroundStyle((isAccent || isActive) ? ClaudeTheme.accent : ClaudeTheme.textSecondary) - .padding(.horizontal, 6) - .padding(.vertical, 5) - .background( - isActive - ? ClaudeTheme.accent.opacity(isHovered ? 0.18 : 0.12) - : (isHovered ? ClaudeTheme.surfaceSecondary.opacity(0.85) : Color.clear), - in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - ) - .overlay( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .strokeBorder( - isActive ? ClaudeTheme.accent.opacity(0.45) : Color.clear, - lineWidth: isActive ? 1 : 0 - ) - ) - .contentShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .onHover { isHovered = $0 } - .pointerCursorOnHover() - .animation(.easeInOut(duration: 0.12), value: isHovered) - .animation(.easeInOut(duration: 0.15), value: isActive) - } -} - -struct ChatDetailModifiers: ViewModifier { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @Environment(ChatBridge.self) private var chatBridge - - private var presentedRequest: PermissionRequest? { - guard let id = windowState.presentedPermissionId else { return nil } - return windowState.pendingPermissions.first { - $0.id == id && $0.sessionId == windowState.currentSessionId - } - } - - private var presentedPermissionModalRequest: PermissionRequest? { - guard let request = presentedRequest, request.toolName != "AskUserQuestion" else { return nil } - return request - } - - private var questionSheetBinding: Binding { - Binding( - get: { presentedRequest?.toolName == "AskUserQuestion" }, - set: { isPresented in - if !isPresented, presentedRequest?.toolName == "AskUserQuestion" { - windowState.presentedPermissionId = nil - } - } - ) - } - - private var presentedPlan: PendingPlan? { - guard let id = windowState.presentedPlanToolCallId else { return nil } - return chatBridge.pendingPlans.first { $0.toolCallId == id } - } - - private var planSheetBinding: Binding { - Binding( - get: { presentedPlan != nil }, - set: { isPresented in - if !isPresented { - windowState.presentedPlanToolCallId = nil - } - } - ) - } - - func body(content: Content) -> some View { - content - .animation(.spring(response: 0.3, dampingFraction: 0.85), value: windowState.pendingPermissions.count) - .overlay { - if let request = presentedPermissionModalRequest { - ZStack { - Color.black.opacity(0.4).ignoresSafeArea() - .onTapGesture { windowState.presentedPermissionId = nil } - PermissionModal(request: request, onClose: { windowState.presentedPermissionId = nil }) - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusLarge)) - .shadow(color: ClaudeTheme.shadowColor, radius: 20) - .transition(.scale(scale: 0.95).combined(with: .opacity)) - } - .animation(.spring(response: 0.3, dampingFraction: 0.85), value: windowState.presentedPermissionId) - } - } - .sheet(isPresented: questionSheetBinding) { - if let request = presentedRequest, request.toolName == "AskUserQuestion" { - QuestionSheetView( - request: request, - remainingCount: max(0, windowState.pendingPermissions.count - 1), - onSubmit: { answers in - windowState.submitQuestionAnswersHandler?(request.id, answers) - }, - onClose: { - // Just dismiss — keep the request in the queue so the user - // can re-open it from the banner later. - windowState.presentedPermissionId = nil - }, - onSkipAll: { - windowState.skipQuestionHandler?(request.id) - } - ) - .environment(appState) - .environment(windowState) - } - } - .onChange(of: windowState.pendingPermissions.map(\.id)) { _, newIds in - if let id = windowState.presentedPermissionId, !newIds.contains(id) { - windowState.presentedPermissionId = nil - } - } - .sheet(isPresented: planSheetBinding) { - if let plan = presentedPlan { - PlanSheetView( - plan: plan, - remainingCount: max(0, chatBridge.pendingPlans.filter { !$0.isDecided }.count - 1), - onSubmit: { toolCallId, action in - windowState.planDecisionHandler?(toolCallId, action) - // Decision recorded — close the sheet. The chip in chat - // will reflect the new status once the result lands. - windowState.presentedPlanToolCallId = nil - }, - onClose: { - // Just dismiss — the plan stays in the queue so the user - // can re-open it from the banner or inline chip later. - windowState.presentedPlanToolCallId = nil - } - ) - .environment(appState) - .environment(windowState) - .environment(chatBridge) - } - } - .onChange(of: chatBridge.pendingPlans.map(\.toolCallId)) { _, newIds in - if let id = windowState.presentedPlanToolCallId, !newIds.contains(id) { - windowState.presentedPlanToolCallId = nil - } - } - .sheet(isPresented: Bindable(windowState).showModelPicker) { - ModelPickerSheet() - .environment(appState) - .environment(windowState) - } - .sheet(isPresented: Bindable(windowState).showEffortPicker) { - EffortPickerSheet() - .environment(appState) - .environment(windowState) - } - .sheet(item: Bindable(windowState).interactiveTerminal) { terminal in - InteractiveTerminalPopup(state: terminal) - } - } -} - -// MARK: - Model Picker Sheet - -struct ModelPickerSheet: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @Environment(\.dismiss) private var dismiss - @State private var selectedIndex: Int = 0 - @FocusState private var isFocused: Bool - - private var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model } - private var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider } - private var flatModels: [AgentModel] { appState.availableAgentModelSections().flatMap(\.models) } - - var body: some View { - VStack(spacing: 16) { - Text("Select Model") - .font(.headline) - .foregroundStyle(ClaudeTheme.textPrimary) - - VStack(spacing: 8) { - ForEach(appState.availableAgentModelSections(), id: \.id) { section in - VStack(alignment: .leading, spacing: 6) { - 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 - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 2) { - Text(model.displayName) - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - .foregroundStyle(ClaudeTheme.textPrimary) - Text(model.description) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer() - if effectiveProvider == model.provider && effectiveModel == model.id { - Image(systemName: "checkmark") - .foregroundStyle(ClaudeTheme.accent) - .padding(.top, 2) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(index == selectedIndex ? ClaudeTheme.accentSubtle : ClaudeTheme.surfacePrimary) - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .onTapGesture { - appState.setSessionModel(model.id, provider: model.provider, in: windowState) - dismiss() - } - } - } - } - } - - Text("↑↓ Select ↵ Confirm esc Cancel") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .padding(20) - .frame(width: 380) - .background(ClaudeTheme.background) - .focusable() - .focused($isFocused) - .onKeyPress(.upArrow) { - selectedIndex = (selectedIndex - 1 + flatModels.count) % flatModels.count - return .handled - } - .onKeyPress(.downArrow) { - selectedIndex = (selectedIndex + 1) % flatModels.count - return .handled - } - .onKeyPress(.return) { - let model = flatModels[selectedIndex] - appState.setSessionModel(model.id, provider: model.provider, in: windowState) - dismiss() - return .handled - } - .onKeyPress(.escape) { - dismiss() - return .handled - } - .onAppear { - selectedIndex = flatModels.firstIndex { $0.provider == effectiveProvider && $0.id == effectiveModel } ?? 0 - DispatchQueue.main.async { isFocused = true } - } - } -} - -// MARK: - Effort Picker Sheet - -struct EffortPickerSheet: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @Environment(\.dismiss) private var dismiss - @State private var selectedIndex: Int = 0 - @FocusState private var isFocused: Bool - - // 0 = Auto (nil), 1...n = availableEfforts - private let items: [String?] = [nil] + AppState.availableEfforts.map { Optional($0) } - - private var effectiveEffort: String? { windowState.sessionEffort } - - var body: some View { - VStack(spacing: 16) { - Text("Select Effort Level") - .font(.headline) - .foregroundStyle(ClaudeTheme.textPrimary) - - VStack(spacing: 8) { - ForEach(items.indices, id: \.self) { index in - let effort = items[index] - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(effort.map { effortDisplayName($0) } ?? "Auto") - .foregroundStyle(ClaudeTheme.textPrimary) - if effort == "max" { - Text("Opus 4.6 only") - .font(.caption2) - .foregroundStyle(ClaudeTheme.textTertiary) - } - } - Spacer() - if effectiveEffort == effort { - Image(systemName: "checkmark") - .foregroundStyle(ClaudeTheme.accent) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(index == selectedIndex ? ClaudeTheme.accentSubtle : ClaudeTheme.surfacePrimary) - .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) - .onTapGesture { - appState.setSessionEffort(effort, in: windowState) - dismiss() - } - } - } - - Text("↑↓ Select ↵ Confirm esc Cancel") - .font(.caption) - .foregroundStyle(ClaudeTheme.textTertiary) - } - .padding(20) - .frame(width: 300) - .background(ClaudeTheme.background) - .focusable() - .focused($isFocused) - .onKeyPress(.upArrow) { - selectedIndex = (selectedIndex - 1 + items.count) % items.count - return .handled - } - .onKeyPress(.downArrow) { - selectedIndex = (selectedIndex + 1) % items.count - return .handled - } - .onKeyPress(.return) { - appState.setSessionEffort(items[selectedIndex], in: windowState) - dismiss() - return .handled - } - .onKeyPress(.escape) { - dismiss() - return .handled - } - .onAppear { - selectedIndex = items.firstIndex(where: { $0 == effectiveEffort }) ?? 0 - DispatchQueue.main.async { isFocused = true } - } - } -} - #Preview { MainView() .environment(AppState()) diff --git a/RxCode/Views/ModelEffortPickerSheets.swift b/RxCode/Views/ModelEffortPickerSheets.swift new file mode 100644 index 0000000..79edde8 --- /dev/null +++ b/RxCode/Views/ModelEffortPickerSheets.swift @@ -0,0 +1,187 @@ +import AppKit +import RxCodeChatKit +import RxCodeCore +import SwiftUI +import TipKit +import UniformTypeIdentifiers + +// MARK: - Model Picker Sheet + +struct ModelPickerSheet: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + @Environment(\.dismiss) var dismiss + @State private var selectedIndex: Int = 0 + @FocusState private var isFocused: Bool + + var effectiveModel: String { appState.effectiveModelSelection(in: windowState).model } + var effectiveProvider: AgentProvider { appState.effectiveModelSelection(in: windowState).provider } + var flatModels: [AgentModel] { appState.availableAgentModelSections().flatMap(\.models) } + + var body: some View { + VStack(spacing: 16) { + Text("Select Model") + .font(.headline) + .foregroundStyle(ClaudeTheme.textPrimary) + + VStack(spacing: 8) { + ForEach(appState.availableAgentModelSections(), id: \.id) { section in + VStack(alignment: .leading, spacing: 6) { + 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 + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(model.displayName) + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text(model.description) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if effectiveProvider == model.provider && effectiveModel == model.id { + Image(systemName: "checkmark") + .foregroundStyle(ClaudeTheme.accent) + .padding(.top, 2) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(index == selectedIndex ? ClaudeTheme.accentSubtle : ClaudeTheme.surfacePrimary) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .onTapGesture { + appState.setSessionModel(model.id, provider: model.provider, in: windowState) + dismiss() + } + } + } + } + } + + Text("↑↓ Select ↵ Confirm esc Cancel") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(20) + .frame(width: 380) + .background(ClaudeTheme.background) + .focusable() + .focused($isFocused) + .onKeyPress(.upArrow) { + selectedIndex = (selectedIndex - 1 + flatModels.count) % flatModels.count + return .handled + } + .onKeyPress(.downArrow) { + selectedIndex = (selectedIndex + 1) % flatModels.count + return .handled + } + .onKeyPress(.return) { + let model = flatModels[selectedIndex] + appState.setSessionModel(model.id, provider: model.provider, in: windowState) + dismiss() + return .handled + } + .onKeyPress(.escape) { + dismiss() + return .handled + } + .onAppear { + selectedIndex = flatModels.firstIndex { $0.provider == effectiveProvider && $0.id == effectiveModel } ?? 0 + DispatchQueue.main.async { isFocused = true } + } + } +} + +// MARK: - Effort Picker Sheet + +struct EffortPickerSheet: View { + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + @Environment(\.dismiss) var dismiss + @State private var selectedIndex: Int = 0 + @FocusState private var isFocused: Bool + + // 0 = Auto (nil), 1...n = availableEfforts + let items: [String?] = [nil] + AppState.availableEfforts.map { Optional($0) } + + var effectiveEffort: String? { windowState.sessionEffort } + + var body: some View { + VStack(spacing: 16) { + Text("Select Effort Level") + .font(.headline) + .foregroundStyle(ClaudeTheme.textPrimary) + + VStack(spacing: 8) { + ForEach(items.indices, id: \.self) { index in + let effort = items[index] + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(effort.map { effortDisplayName($0) } ?? "Auto") + .foregroundStyle(ClaudeTheme.textPrimary) + if effort == "max" { + Text("Opus 4.6 only") + .font(.caption2) + .foregroundStyle(ClaudeTheme.textTertiary) + } + } + Spacer() + if effectiveEffort == effort { + Image(systemName: "checkmark") + .foregroundStyle(ClaudeTheme.accent) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(index == selectedIndex ? ClaudeTheme.accentSubtle : ClaudeTheme.surfacePrimary) + .clipShape(RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) + .onTapGesture { + appState.setSessionEffort(effort, in: windowState) + dismiss() + } + } + } + + Text("↑↓ Select ↵ Confirm esc Cancel") + .font(.caption) + .foregroundStyle(ClaudeTheme.textTertiary) + } + .padding(20) + .frame(width: 300) + .background(ClaudeTheme.background) + .focusable() + .focused($isFocused) + .onKeyPress(.upArrow) { + selectedIndex = (selectedIndex - 1 + items.count) % items.count + return .handled + } + .onKeyPress(.downArrow) { + selectedIndex = (selectedIndex + 1) % items.count + return .handled + } + .onKeyPress(.return) { + appState.setSessionEffort(items[selectedIndex], in: windowState) + dismiss() + return .handled + } + .onKeyPress(.escape) { + dismiss() + return .handled + } + .onAppear { + selectedIndex = items.firstIndex(where: { $0 == effectiveEffort }) ?? 0 + DispatchQueue.main.async { isFocused = true } + } + } +} diff --git a/RxCode/Views/RunProfile/RunConfigurationsView.swift b/RxCode/Views/RunProfile/RunConfigurationsView.swift index fe9e2d9..cff6e5c 100644 --- a/RxCode/Views/RunProfile/RunConfigurationsView.swift +++ b/RxCode/Views/RunProfile/RunConfigurationsView.swift @@ -6,17 +6,17 @@ import UniformTypeIdentifiers /// Modal sheet for editing run profiles — JetBrains "Run/Debug Configurations" /// equivalent. Left list of profiles, right form pinned to the selected one. struct RunConfigurationsView: View { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) var appState + @Environment(WindowState.self) var windowState + @Environment(\.dismiss) var dismiss let project: Project - @State private var draft: [RunProfile] = [] - @State private var selectedId: UUID? - @State private var detected: DetectedRunnables = .init() + @State var draft: [RunProfile] = [] + @State var selectedId: UUID? + @State var detected: DetectedRunnables = .init() - private var selectedIndex: Int? { + var selectedIndex: Int? { guard let id = selectedId else { return nil } return draft.firstIndex { $0.id == id } } @@ -46,7 +46,7 @@ struct RunConfigurationsView: View { // MARK: - Sections - private var header: some View { + var header: some View { HStack { Text("Run/Debug Configurations") .font(.headline) @@ -56,7 +56,7 @@ struct RunConfigurationsView: View { .padding(.vertical, 12) } - private var profileList: some View { + var profileList: some View { VStack(spacing: 0) { HStack(spacing: 4) { addMenu @@ -128,7 +128,7 @@ struct RunConfigurationsView: View { } @ViewBuilder - private var detailPane: some View { + var detailPane: some View { if let idx = selectedIndex { RunProfileDetailForm( profile: $draft[idx], @@ -146,7 +146,7 @@ struct RunConfigurationsView: View { } } - private var footer: some View { + var footer: some View { HStack { Spacer() Button("Cancel") { dismiss() } @@ -161,7 +161,7 @@ struct RunConfigurationsView: View { } @ViewBuilder - private func profileRow(_ profile: RunProfile) -> some View { + func profileRow(_ profile: RunProfile) -> some View { HStack { Image(systemName: iconName(for: profile.type)) .foregroundStyle(ClaudeTheme.accent) @@ -170,7 +170,7 @@ struct RunConfigurationsView: View { .tag(profile.id) } - private func iconName(for type: RunProfileType) -> String { + func iconName(for type: RunProfileType) -> String { switch type { case .xcode: return "hammer.fill" case .make: return "wrench.and.screwdriver.fill" @@ -180,7 +180,7 @@ struct RunConfigurationsView: View { // MARK: - Add menu (detected runnables) - private var addMenu: some View { + var addMenu: some View { Menu { Section("Xcode") { Button { @@ -237,7 +237,7 @@ struct RunConfigurationsView: View { // MARK: - Actions - private func addProfile(from runnable: DetectedRunnable? = nil) { + func addProfile(from runnable: DetectedRunnable? = nil) { let now = Date() if let xcode = runnable?.xcode { let new = RunProfile( @@ -276,7 +276,7 @@ struct RunConfigurationsView: View { selectedId = new.id } - private func addXcodeProfile() { + func addXcodeProfile() { let now = Date() let firstDetected = detected.xcode.first?.xcode let new = RunProfile( @@ -291,7 +291,7 @@ struct RunConfigurationsView: View { selectedId = new.id } - private func addMakeProfile() { + func addMakeProfile() { let now = Date() let firstDetected = detected.make.first?.make let new = RunProfile( @@ -306,7 +306,7 @@ struct RunConfigurationsView: View { selectedId = new.id } - private func deleteSelected() { + func deleteSelected() { guard let idx = selectedIndex else { return } let removed = draft.remove(at: idx) if selectedId == removed.id { @@ -314,7 +314,7 @@ struct RunConfigurationsView: View { } } - private func duplicateSelected() { + func duplicateSelected() { guard let idx = selectedIndex else { return } var copy = draft[idx] copy.id = UUID() @@ -325,7 +325,7 @@ struct RunConfigurationsView: View { selectedId = copy.id } - private func applyAndDismiss() { + func applyAndDismiss() { // Stamp updatedAt on whatever changed. let now = Date() let stamped = draft.map { profile -> RunProfile in @@ -342,585 +342,3 @@ struct RunConfigurationsView: View { dismiss() } } - -// MARK: - Detail form - -private struct RunProfileDetailForm: View { - @Binding var profile: RunProfile - let project: Project - - @State private var detectedMakeTargets: [String] = [] - private static let customTargetSentinel = "__rxcode_custom_target__" - - var body: some View { - Form { - Section { - TextField("Name", text: $profile.name) - Picker("Type", selection: Binding( - get: { profile.type }, - set: { newValue in - profile.type = newValue - if newValue == .xcode, profile.xcode == nil { - profile.xcode = XcodeRunConfig() - } - if newValue == .make, profile.make == nil { - profile.make = MakeRunConfig() - } - } - )) { - ForEach(RunProfileType.allCases, id: \.self) { type in - Text(type.rawValue.capitalized).tag(type) - } - } - } header: { - Text("Configuration") - } - - switch profile.type { - case .bash: - bashCommandSection - case .xcode: - xcodeCommandSection - case .make: - makeCommandSection - } - - environmentsSection - - stepsSection( - title: "Before Launch", - steps: Binding(get: { profile.beforeSteps }, set: { profile.beforeSteps = $0 }) - ) - - stepsSection( - title: "After Launch", - steps: Binding(get: { profile.afterSteps }, set: { profile.afterSteps = $0 }) - ) - } - .formStyle(.grouped) - } - - // MARK: - Command sections - - @ViewBuilder - private var bashCommandSection: some View { - Section { - TextEditor(text: $profile.bash.command) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 100) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) - ) - HStack { - TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) - Button("Browse…") { - pickDirectory { picked in - profile.bash.workingDirectory = picked - } - } - Button { - profile.bash.workingDirectory = "" - } label: { - Image(systemName: "arrow.uturn.backward") - } - .help("Reset to project root") - } - } header: { - Text("Command") - } footer: { - Text("Absolute or project-relative path. Leave empty to use the project root.") - } - } - - @ViewBuilder - private var xcodeCommandSection: some View { - Section { - let xcode = Binding( - get: { profile.xcode ?? XcodeRunConfig() }, - set: { profile.xcode = $0 } - ) - HStack { - TextField( - "Project / Workspace", - text: xcode.container, - prompt: Text("RxCode.xcodeproj") - ) - .font(.system(.body, design: .monospaced)) - Button("Browse…") { - pickXcodeContainer { name, isWorkspace in - var c = xcode.wrappedValue - c.container = name - c.isWorkspace = isWorkspace - xcode.wrappedValue = c - } - } - } - Toggle("Use Workspace (.xcworkspace)", isOn: xcode.isWorkspace) - TextField("Scheme", text: xcode.scheme, prompt: Text("RxCode")) - .font(.system(.body, design: .monospaced)) - TextField("Configuration", text: xcode.configuration, prompt: Text("Debug")) - .font(.system(.body, design: .monospaced)) - Picker("Action", selection: xcode.action) { - ForEach(XcodeAction.allCases, id: \.self) { action in - Text(action.rawValue.capitalized).tag(action) - } - } - LabeledContent("Destination") { - HStack(spacing: 6) { - Text(xcode.wrappedValue.selectedDestination?.displayName ?? "Any Mac (default)") - .foregroundStyle(.secondary) - if xcode.wrappedValue.selectedDestination != nil { - Button { - var c = xcode.wrappedValue - c.selectedDestination = nil - xcode.wrappedValue = c - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - } - .buttonStyle(.plain) - .help("Clear destination — pick again from the toolbar") - } - } - } - } header: { - Text("Xcode") - } footer: { - Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app (macOS) or installs + launches on the selected simulator. Pick the destination from the Run toolbar.") - } - } - - @ViewBuilder - private var makeCommandSection: some View { - Section { - let make = Binding( - get: { profile.make ?? MakeRunConfig() }, - set: { profile.make = $0 } - ) - HStack { - TextField( - "Makefile (optional)", - text: make.makefile, - prompt: Text("Makefile") - ) - .font(.system(.body, design: .monospaced)) - Button("Browse…") { - pickFile { picked in - var c = make.wrappedValue - c.makefile = picked - make.wrappedValue = c - } - } - Button { - var c = make.wrappedValue - c.makefile = "" - make.wrappedValue = c - } label: { - Image(systemName: "arrow.uturn.backward") - } - .help("Use default Makefile lookup") - } - targetField(make: make) - TextField( - "Arguments (optional)", - text: make.arguments, - prompt: Text("VAR=value -j8") - ) - .font(.system(.body, design: .monospaced)) - HStack { - TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) - Button("Browse…") { - pickDirectory { picked in - profile.bash.workingDirectory = picked - } - } - Button { - profile.bash.workingDirectory = "" - } label: { - Image(systemName: "arrow.uturn.backward") - } - .help("Reset to project root") - } - } header: { - Text("Make") - } footer: { - Text("Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile).") - } - .task(id: makeDetectionKey) { - detectedMakeTargets = await refreshMakeTargets() - } - } - - /// Cache key for re-parsing the Makefile when its path or the working - /// directory changes. - private var makeDetectionKey: String { - "\(profile.id.uuidString)|\(profile.make?.makefile ?? "")|\(profile.bash.workingDirectory)" - } - - private func refreshMakeTargets() async -> [String] { - let projectRoot = project.path - let makefile = profile.make?.makefile ?? "" - let workingDirRaw = profile.bash.workingDirectory - return await Task.detached { () -> [String] in - let fm = FileManager.default - func resolve(_ p: String, against base: String) -> String { - if p.isEmpty { return base } - if p.hasPrefix("/") { return p } - return (base as NSString).appendingPathComponent(p) - } - let workingDir = workingDirRaw.isEmpty - ? projectRoot - : resolve(workingDirRaw, against: projectRoot) - - if !makefile.isEmpty { - // Picker produces project-relative paths; the executor resolves - // relative to working dir. Try both so the dropdown works - // regardless of which the user typed. - let candidates = [ - resolve(makefile, against: projectRoot), - resolve(makefile, against: workingDir), - ] - for path in candidates where fm.fileExists(atPath: path) { - return RunProfileDetector.makeTargets(atPath: path) - } - return [] - } - if let result = RunProfileDetector.defaultMakeTargets(inDirectory: workingDir) { - return result.targets - } - return RunProfileDetector.defaultMakeTargets(inDirectory: projectRoot)?.targets ?? [] - }.value - } - - @ViewBuilder - private func targetField(make: Binding) -> some View { - let current = make.wrappedValue.target - let targets = detectedMakeTargets - let isCustom = !targets.isEmpty && !current.isEmpty && !targets.contains(current) - - if targets.isEmpty { - TextField("Target", text: make.target, prompt: Text("build")) - .font(.system(.body, design: .monospaced)) - } else { - Picker("Target", selection: Binding( - get: { - if current.isEmpty { return "" } - return isCustom ? Self.customTargetSentinel : current - }, - set: { newValue in - var c = make.wrappedValue - if newValue == Self.customTargetSentinel { - if targets.contains(c.target) { c.target = "" } - } else { - c.target = newValue - } - make.wrappedValue = c - } - )) { - if current.isEmpty { - Text("Select a target…").tag("") - } - ForEach(targets, id: \.self) { t in - Text(t).tag(t) - } - Divider() - Text("Custom…").tag(Self.customTargetSentinel) - } - .font(.system(.body, design: .monospaced)) - - if isCustom { - TextField("Custom target", text: make.target, prompt: Text("build")) - .font(.system(.body, design: .monospaced)) - } - } - } - - private func pickXcodeContainer(onPick: @escaping (String, Bool) -> Void) { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: project.path) - panel.allowedContentTypes = [ - UTType(filenameExtension: "xcodeproj") ?? .package, - UTType(filenameExtension: "xcworkspace") ?? .package, - ] - guard panel.runModal() == .OK, let url = panel.url else { return } - let name: String - let root = (project.path as NSString).standardizingPath - let std = (url.path as NSString).standardizingPath - if std.hasPrefix(root + "/") { - name = String(std.dropFirst(root.count + 1)) - } else { - name = std - } - onPick(name, url.pathExtension == "xcworkspace") - } - - // MARK: - Environments - - @State private var newPresetName: String = "" - - private var activePresetIndex: Int? { - guard let id = profile.bash.activePresetId ?? profile.bash.environments.first?.id else { return nil } - return profile.bash.environments.firstIndex { $0.id == id } - } - - @ViewBuilder - private var environmentsSection: some View { - Section { - LabeledContent("Preset") { - HStack { - Picker("", selection: Binding( - get: { profile.bash.activePresetId ?? profile.bash.environments.first?.id }, - set: { profile.bash.activePresetId = $0 } - )) { - if profile.bash.environments.isEmpty { - Text("None").tag(UUID?.none) - } else { - ForEach(profile.bash.environments) { preset in - Text(preset.name.isEmpty ? "Untitled" : preset.name) - .tag(UUID?.some(preset.id)) - } - } - } - .labelsHidden() - .frame(width: 200) - Button { - addPreset() - } label: { - Image(systemName: "plus") - } - .help("Add Preset") - Button { - deleteActivePreset() - } label: { - Image(systemName: "minus") - } - .disabled(profile.bash.environments.isEmpty) - .help("Delete Preset") - } - } - if let idx = activePresetIndex { - presetEditor(idx: idx) - } - } header: { - Text("Environments") - } footer: { - if activePresetIndex == nil { - Text("Add a preset (e.g. \"dev\", \"prod\") to configure environment variables.") - } - } - } - - @ViewBuilder - private func presetEditor(idx: Int) -> some View { - let preset = Binding( - get: { profile.bash.environments[idx] }, - set: { profile.bash.environments[idx] = $0 } - ) - - TextField("Preset Name", text: preset.name, prompt: Text("dev / prod / beta")) - - Toggle("Load from .env file", isOn: preset.loadFromFile) - - if preset.wrappedValue.loadFromFile { - LabeledContent("Env File") { - HStack { - TextField(".env", text: Binding( - get: { preset.wrappedValue.envFilePath ?? "" }, - set: { preset.wrappedValue.envFilePath = $0 } - )) - Button("Browse…") { - pickFile { picked in - preset.wrappedValue.envFilePath = picked - } - } - } - } - } - - Toggle("Manual key/value pairs", isOn: preset.useManualKV) - - if preset.wrappedValue.useManualKV { - manualKVTable(preset: preset) - } - } - - private func addPreset() { - let preset = EnvironmentPreset( - name: profile.bash.environments.isEmpty ? "dev" : "preset \(profile.bash.environments.count + 1)" - ) - profile.bash.environments.append(preset) - profile.bash.activePresetId = preset.id - } - - private func deleteActivePreset() { - guard let idx = activePresetIndex else { return } - let removed = profile.bash.environments.remove(at: idx) - if profile.bash.activePresetId == removed.id { - profile.bash.activePresetId = profile.bash.environments.first?.id - } - } - - @ViewBuilder - private func manualKVTable(preset: Binding) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Header row with column titles and add button - HStack(spacing: 8) { - Text("Name") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: 140, alignment: .leading) - Text("Value") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - Button { - preset.wrappedValue.manualVars.append(EnvVar()) - } label: { - Image(systemName: "plus.circle.fill") - .foregroundStyle(ClaudeTheme.accent) - } - .buttonStyle(.plain) - .help("Add Variable") - } - - if preset.wrappedValue.manualVars.isEmpty { - HStack { - Spacer() - Text("No environment variables defined") - .foregroundStyle(.tertiary) - .font(.system(size: 12)) - Spacer() - } - .padding(.vertical, 12) - } else { - // Variable rows - ForEach(preset.wrappedValue.manualVars.indices, id: \.self) { i in - HStack(spacing: 8) { - TextField("API_KEY", text: Binding( - get: { preset.wrappedValue.manualVars[i].key }, - set: { preset.wrappedValue.manualVars[i].key = $0 } - )) - .textFieldStyle(.roundedBorder) - .font(.system(.body, design: .monospaced)) - .frame(width: 140) - - TextField("value", text: Binding( - get: { preset.wrappedValue.manualVars[i].value }, - set: { preset.wrappedValue.manualVars[i].value = $0 } - )) - .textFieldStyle(.roundedBorder) - .font(.system(.body, design: .monospaced)) - - Button { - preset.wrappedValue.manualVars.remove(at: i) - } label: { - Image(systemName: "trash") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .help("Remove Variable") - } - } - } - } - .padding(.vertical, 4) - } - - // MARK: - Before / after steps - - @ViewBuilder - private func stepsSection(title: String, steps: Binding<[RunStep]>) -> some View { - Section { - if steps.wrappedValue.isEmpty { - Text("There are no tasks to run \(title.lowercased()).") - .foregroundStyle(.secondary) - } else { - ForEach(steps.wrappedValue.indices, id: \.self) { i in - HStack(spacing: 6) { - Picker("", selection: Binding( - get: { steps.wrappedValue[i].type }, - set: { steps.wrappedValue[i].type = $0 } - )) { - ForEach(RunStepType.allCases, id: \.self) { t in - Text(t.rawValue.capitalized).tag(t) - } - } - .labelsHidden() - .frame(width: 80) - TextField("command", text: Binding( - get: { steps.wrappedValue[i].command }, - set: { steps.wrappedValue[i].command = $0 } - )) - .font(.system(.body, design: .monospaced)) - Button { - if i > 0 { steps.wrappedValue.swapAt(i, i - 1) } - } label: { Image(systemName: "arrow.up") } - .disabled(i == 0) - Button { - if i < steps.wrappedValue.count - 1 { steps.wrappedValue.swapAt(i, i + 1) } - } label: { Image(systemName: "arrow.down") } - .disabled(i == steps.wrappedValue.count - 1) - Button { - steps.wrappedValue.remove(at: i) - } label: { Image(systemName: "xmark.circle") } - .buttonStyle(.borderless) - } - } - } - } header: { - HStack { - Text(title) - Spacer() - Menu { - Button("Bash") { - steps.wrappedValue.append(RunStep(type: .bash, command: "")) - } - } label: { - Image(systemName: "plus") - } - .menuStyle(.borderlessButton) - .fixedSize() - } - } - } - - // MARK: - File pickers - - private func pickDirectory(onPick: @escaping (String) -> Void) { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: project.path) - if panel.runModal() == .OK, let url = panel.url { - onPick(displayPath(for: url)) - } - } - - private func pickFile(onPick: @escaping (String) -> Void) { - let panel = NSOpenPanel() - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: project.path) - if panel.runModal() == .OK, let url = panel.url { - onPick(displayPath(for: url)) - } - } - - /// If the picked URL is inside the project root, return a project-relative - /// path; otherwise return the absolute path. - private func displayPath(for url: URL) -> String { - let absolute = url.path - let root = (project.path as NSString).standardizingPath - let std = (absolute as NSString).standardizingPath - if std.hasPrefix(root + "/") { - return "./" + String(std.dropFirst(root.count + 1)) - } - return std - } -} diff --git a/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift b/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift new file mode 100644 index 0000000..3ca5a5e --- /dev/null +++ b/RxCode/Views/RunProfile/RunProfileDetailForm+Environments.swift @@ -0,0 +1,198 @@ +import AppKit +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Environments + +extension RunProfileDetailForm { + func pickXcodeContainer(onPick: @escaping (String, Bool) -> Void) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: project.path) + panel.allowedContentTypes = [ + UTType(filenameExtension: "xcodeproj") ?? .package, + UTType(filenameExtension: "xcworkspace") ?? .package, + ] + guard panel.runModal() == .OK, let url = panel.url else { return } + let name: String + let root = (project.path as NSString).standardizingPath + let std = (url.path as NSString).standardizingPath + if std.hasPrefix(root + "/") { + name = String(std.dropFirst(root.count + 1)) + } else { + name = std + } + onPick(name, url.pathExtension == "xcworkspace") + } + + var activePresetIndex: Int? { + guard let id = profile.bash.activePresetId ?? profile.bash.environments.first?.id else { return nil } + return profile.bash.environments.firstIndex { $0.id == id } + } + + @ViewBuilder + var environmentsSection: some View { + Section { + LabeledContent("Preset") { + HStack { + Picker("", selection: Binding( + get: { profile.bash.activePresetId ?? profile.bash.environments.first?.id }, + set: { profile.bash.activePresetId = $0 } + )) { + if profile.bash.environments.isEmpty { + Text("None").tag(UUID?.none) + } else { + ForEach(profile.bash.environments) { preset in + Text(preset.name.isEmpty ? "Untitled" : preset.name) + .tag(UUID?.some(preset.id)) + } + } + } + .labelsHidden() + .frame(width: 200) + Button { + addPreset() + } label: { + Image(systemName: "plus") + } + .help("Add Preset") + Button { + deleteActivePreset() + } label: { + Image(systemName: "minus") + } + .disabled(profile.bash.environments.isEmpty) + .help("Delete Preset") + } + } + if let idx = activePresetIndex { + presetEditor(idx: idx) + } + } header: { + Text("Environments") + } footer: { + if activePresetIndex == nil { + Text("Add a preset (e.g. \"dev\", \"prod\") to configure environment variables.") + } + } + } + + @ViewBuilder + func presetEditor(idx: Int) -> some View { + let preset = Binding( + get: { profile.bash.environments[idx] }, + set: { profile.bash.environments[idx] = $0 } + ) + + TextField("Preset Name", text: preset.name, prompt: Text("dev / prod / beta")) + + Toggle("Load from .env file", isOn: preset.loadFromFile) + + if preset.wrappedValue.loadFromFile { + LabeledContent("Env File") { + HStack { + TextField(".env", text: Binding( + get: { preset.wrappedValue.envFilePath ?? "" }, + set: { preset.wrappedValue.envFilePath = $0 } + )) + Button("Browse…") { + pickFile { picked in + preset.wrappedValue.envFilePath = picked + } + } + } + } + } + + Toggle("Manual key/value pairs", isOn: preset.useManualKV) + + if preset.wrappedValue.useManualKV { + manualKVTable(preset: preset) + } + } + + func addPreset() { + let preset = EnvironmentPreset( + name: profile.bash.environments.isEmpty ? "dev" : "preset \(profile.bash.environments.count + 1)" + ) + profile.bash.environments.append(preset) + profile.bash.activePresetId = preset.id + } + + func deleteActivePreset() { + guard let idx = activePresetIndex else { return } + let removed = profile.bash.environments.remove(at: idx) + if profile.bash.activePresetId == removed.id { + profile.bash.activePresetId = profile.bash.environments.first?.id + } + } + + @ViewBuilder + func manualKVTable(preset: Binding) -> some View { + VStack(alignment: .leading, spacing: 8) { + // Header row with column titles and add button + HStack(spacing: 8) { + Text("Name") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .leading) + Text("Value") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + preset.wrappedValue.manualVars.append(EnvVar()) + } label: { + Image(systemName: "plus.circle.fill") + .foregroundStyle(ClaudeTheme.accent) + } + .buttonStyle(.plain) + .help("Add Variable") + } + + if preset.wrappedValue.manualVars.isEmpty { + HStack { + Spacer() + Text("No environment variables defined") + .foregroundStyle(.tertiary) + .font(.system(size: 12)) + Spacer() + } + .padding(.vertical, 12) + } else { + // Variable rows + ForEach(preset.wrappedValue.manualVars.indices, id: \.self) { i in + HStack(spacing: 8) { + TextField("API_KEY", text: Binding( + get: { preset.wrappedValue.manualVars[i].key }, + set: { preset.wrappedValue.manualVars[i].key = $0 } + )) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .frame(width: 140) + + TextField("value", text: Binding( + get: { preset.wrappedValue.manualVars[i].value }, + set: { preset.wrappedValue.manualVars[i].value = $0 } + )) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + + Button { + preset.wrappedValue.manualVars.remove(at: i) + } label: { + Image(systemName: "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Remove Variable") + } + } + } + } + .padding(.vertical, 4) + } +} diff --git a/RxCode/Views/RunProfile/RunProfileDetailForm+Steps.swift b/RxCode/Views/RunProfile/RunProfileDetailForm+Steps.swift new file mode 100644 index 0000000..dcfc9e4 --- /dev/null +++ b/RxCode/Views/RunProfile/RunProfileDetailForm+Steps.swift @@ -0,0 +1,100 @@ +import AppKit +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Before / after steps + +extension RunProfileDetailForm { + @ViewBuilder + func stepsSection(title: String, steps: Binding<[RunStep]>) -> some View { + Section { + if steps.wrappedValue.isEmpty { + Text("There are no tasks to run \(title.lowercased()).") + .foregroundStyle(.secondary) + } else { + ForEach(steps.wrappedValue.indices, id: \.self) { i in + HStack(spacing: 6) { + Picker("", selection: Binding( + get: { steps.wrappedValue[i].type }, + set: { steps.wrappedValue[i].type = $0 } + )) { + ForEach(RunStepType.allCases, id: \.self) { t in + Text(t.rawValue.capitalized).tag(t) + } + } + .labelsHidden() + .frame(width: 80) + TextField("command", text: Binding( + get: { steps.wrappedValue[i].command }, + set: { steps.wrappedValue[i].command = $0 } + )) + .font(.system(.body, design: .monospaced)) + Button { + if i > 0 { steps.wrappedValue.swapAt(i, i - 1) } + } label: { Image(systemName: "arrow.up") } + .disabled(i == 0) + Button { + if i < steps.wrappedValue.count - 1 { steps.wrappedValue.swapAt(i, i + 1) } + } label: { Image(systemName: "arrow.down") } + .disabled(i == steps.wrappedValue.count - 1) + Button { + steps.wrappedValue.remove(at: i) + } label: { Image(systemName: "xmark.circle") } + .buttonStyle(.borderless) + } + } + } + } header: { + HStack { + Text(title) + Spacer() + Menu { + Button("Bash") { + steps.wrappedValue.append(RunStep(type: .bash, command: "")) + } + } label: { + Image(systemName: "plus") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } + } + + // MARK: - File pickers + + func pickDirectory(onPick: @escaping (String) -> Void) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: project.path) + if panel.runModal() == .OK, let url = panel.url { + onPick(displayPath(for: url)) + } + } + + func pickFile(onPick: @escaping (String) -> Void) { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: project.path) + if panel.runModal() == .OK, let url = panel.url { + onPick(displayPath(for: url)) + } + } + + /// If the picked URL is inside the project root, return a project-relative + /// path; otherwise return the absolute path. + func displayPath(for url: URL) -> String { + let absolute = url.path + let root = (project.path as NSString).standardizingPath + let std = (absolute as NSString).standardizingPath + if std.hasPrefix(root + "/") { + return "./" + String(std.dropFirst(root.count + 1)) + } + return std + } +} diff --git a/RxCode/Views/RunProfile/RunProfileDetailForm.swift b/RxCode/Views/RunProfile/RunProfileDetailForm.swift new file mode 100644 index 0000000..430d095 --- /dev/null +++ b/RxCode/Views/RunProfile/RunProfileDetailForm.swift @@ -0,0 +1,299 @@ +import AppKit +import RxCodeCore +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Detail form + +struct RunProfileDetailForm: View { + @Binding var profile: RunProfile + let project: Project + + @State var detectedMakeTargets: [String] = [] + @State var newPresetName: String = "" + static let customTargetSentinel = "__rxcode_custom_target__" + + var body: some View { + Form { + Section { + TextField("Name", text: $profile.name) + Picker("Type", selection: Binding( + get: { profile.type }, + set: { newValue in + profile.type = newValue + if newValue == .xcode, profile.xcode == nil { + profile.xcode = XcodeRunConfig() + } + if newValue == .make, profile.make == nil { + profile.make = MakeRunConfig() + } + } + )) { + ForEach(RunProfileType.allCases, id: \.self) { type in + Text(type.rawValue.capitalized).tag(type) + } + } + } header: { + Text("Configuration") + } + + switch profile.type { + case .bash: + bashCommandSection + case .xcode: + xcodeCommandSection + case .make: + makeCommandSection + } + + environmentsSection + + stepsSection( + title: "Before Launch", + steps: Binding(get: { profile.beforeSteps }, set: { profile.beforeSteps = $0 }) + ) + + stepsSection( + title: "After Launch", + steps: Binding(get: { profile.afterSteps }, set: { profile.afterSteps = $0 }) + ) + } + .formStyle(.grouped) + } + + // MARK: - Command sections + + @ViewBuilder + var bashCommandSection: some View { + Section { + TextEditor(text: $profile.bash.command) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(ClaudeTheme.borderSubtle, lineWidth: 0.5) + ) + HStack { + TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + profile.bash.workingDirectory = picked + } + } + Button { + profile.bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Command") + } footer: { + Text("Absolute or project-relative path. Leave empty to use the project root.") + } + } + + @ViewBuilder + var xcodeCommandSection: some View { + Section { + let xcode = Binding( + get: { profile.xcode ?? XcodeRunConfig() }, + set: { profile.xcode = $0 } + ) + HStack { + TextField( + "Project / Workspace", + text: xcode.container, + prompt: Text("RxCode.xcodeproj") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickXcodeContainer { name, isWorkspace in + var c = xcode.wrappedValue + c.container = name + c.isWorkspace = isWorkspace + xcode.wrappedValue = c + } + } + } + Toggle("Use Workspace (.xcworkspace)", isOn: xcode.isWorkspace) + TextField("Scheme", text: xcode.scheme, prompt: Text("RxCode")) + .font(.system(.body, design: .monospaced)) + TextField("Configuration", text: xcode.configuration, prompt: Text("Debug")) + .font(.system(.body, design: .monospaced)) + Picker("Action", selection: xcode.action) { + ForEach(XcodeAction.allCases, id: \.self) { action in + Text(action.rawValue.capitalized).tag(action) + } + } + LabeledContent("Destination") { + HStack(spacing: 6) { + Text(xcode.wrappedValue.selectedDestination?.displayName ?? "Any Mac (default)") + .foregroundStyle(.secondary) + if xcode.wrappedValue.selectedDestination != nil { + Button { + var c = xcode.wrappedValue + c.selectedDestination = nil + xcode.wrappedValue = c + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help("Clear destination — pick again from the toolbar") + } + } + } + } header: { + Text("Xcode") + } footer: { + Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app (macOS) or installs + launches on the selected simulator. Pick the destination from the Run toolbar.") + } + } + + @ViewBuilder + var makeCommandSection: some View { + Section { + let make = Binding( + get: { profile.make ?? MakeRunConfig() }, + set: { profile.make = $0 } + ) + HStack { + TextField( + "Makefile (optional)", + text: make.makefile, + prompt: Text("Makefile") + ) + .font(.system(.body, design: .monospaced)) + Button("Browse…") { + pickFile { picked in + var c = make.wrappedValue + c.makefile = picked + make.wrappedValue = c + } + } + Button { + var c = make.wrappedValue + c.makefile = "" + make.wrappedValue = c + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Use default Makefile lookup") + } + targetField(make: make) + TextField( + "Arguments (optional)", + text: make.arguments, + prompt: Text("VAR=value -j8") + ) + .font(.system(.body, design: .monospaced)) + HStack { + TextField("Working Directory", text: $profile.bash.workingDirectory, prompt: Text(project.path)) + Button("Browse…") { + pickDirectory { picked in + profile.bash.workingDirectory = picked + } + } + Button { + profile.bash.workingDirectory = "" + } label: { + Image(systemName: "arrow.uturn.backward") + } + .help("Reset to project root") + } + } header: { + Text("Make") + } footer: { + Text("Runs `make [-f ] [arguments]`. Leave Makefile empty to use the default lookup (Makefile / makefile / GNUmakefile).") + } + .task(id: makeDetectionKey) { + detectedMakeTargets = await refreshMakeTargets() + } + } + + /// Cache key for re-parsing the Makefile when its path or the working + /// directory changes. + var makeDetectionKey: String { + "\(profile.id.uuidString)|\(profile.make?.makefile ?? "")|\(profile.bash.workingDirectory)" + } + + func refreshMakeTargets() async -> [String] { + let projectRoot = project.path + let makefile = profile.make?.makefile ?? "" + let workingDirRaw = profile.bash.workingDirectory + return await Task.detached { () -> [String] in + let fm = FileManager.default + func resolve(_ p: String, against base: String) -> String { + if p.isEmpty { return base } + if p.hasPrefix("/") { return p } + return (base as NSString).appendingPathComponent(p) + } + let workingDir = workingDirRaw.isEmpty + ? projectRoot + : resolve(workingDirRaw, against: projectRoot) + + if !makefile.isEmpty { + // Picker produces project-relative paths; the executor resolves + // relative to working dir. Try both so the dropdown works + // regardless of which the user typed. + let candidates = [ + resolve(makefile, against: projectRoot), + resolve(makefile, against: workingDir), + ] + for path in candidates where fm.fileExists(atPath: path) { + return RunProfileDetector.makeTargets(atPath: path) + } + return [] + } + if let result = RunProfileDetector.defaultMakeTargets(inDirectory: workingDir) { + return result.targets + } + return RunProfileDetector.defaultMakeTargets(inDirectory: projectRoot)?.targets ?? [] + }.value + } + + @ViewBuilder + func targetField(make: Binding) -> some View { + let current = make.wrappedValue.target + let targets = detectedMakeTargets + let isCustom = !targets.isEmpty && !current.isEmpty && !targets.contains(current) + + if targets.isEmpty { + TextField("Target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } else { + Picker("Target", selection: Binding( + get: { + if current.isEmpty { return "" } + return isCustom ? Self.customTargetSentinel : current + }, + set: { newValue in + var c = make.wrappedValue + if newValue == Self.customTargetSentinel { + if targets.contains(c.target) { c.target = "" } + } else { + c.target = newValue + } + make.wrappedValue = c + } + )) { + if current.isEmpty { + Text("Select a target…").tag("") + } + ForEach(targets, id: \.self) { t in + Text(t).tag(t) + } + Divider() + Text("Custom…").tag(Self.customTargetSentinel) + } + .font(.system(.body, design: .monospaced)) + + if isCustom { + TextField("Custom target", text: make.target, prompt: Text("build")) + .font(.system(.body, design: .monospaced)) + } + } + } +} diff --git a/RxCode/Views/SettingsMemoryViews.swift b/RxCode/Views/SettingsMemoryViews.swift new file mode 100644 index 0000000..4a491b1 --- /dev/null +++ b/RxCode/Views/SettingsMemoryViews.swift @@ -0,0 +1,441 @@ +import RxCodeChatKit +import RxCodeCore +import SwiftUI +import TipKit + +// MARK: - Memory Settings Section + +struct MemorySettingsSection: View { + @Environment(AppState.self) private var appState + + @State private var editingDraft: MemoryDraft? + @State private var showMemoryBrowser = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + controlsSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .sheet(item: $editingDraft) { draft in + MemoryEditorSheet(draft: draft) + } + .sheet(isPresented: $showMemoryBrowser) { + MemoryBrowserSheet() + .environment(appState) + } + } + + private var controlsSection: some View { + @Bindable var appState = appState + return VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Memory") + .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) + Spacer() + Button { + editingDraft = MemoryDraft() + } label: { + Label("Add", systemImage: "plus") + } + .help("Add memory") + Button { + showMemoryBrowser = true + } label: { + Label("Manage", systemImage: "list.bullet.rectangle") + } + .help("View and manage saved memories") + } + + Toggle(isOn: $appState.memoryEnabled) { + Text("Enable Memory") + } + .toggleStyle(.switch) + .fixedSize() + + Toggle(isOn: $appState.memoryAutoCreateEnabled) { + Text("Auto-create memories from completed chats") + } + .toggleStyle(.switch) + .fixedSize() + .disabled(!appState.memoryEnabled) + + Toggle(isOn: $appState.memoryInjectEnabled) { + Text("Include relevant memories in agent context") + } + .toggleStyle(.switch) + .fixedSize() + .disabled(!appState.memoryEnabled) + + Stepper(value: $appState.memoryMaxContextItems, in: 1...12) { + Text("Context memories: \(appState.memoryMaxContextItems)") + .font(.system(size: ClaudeTheme.size(12))) + } + .fixedSize() + .disabled(!appState.memoryEnabled || !appState.memoryInjectEnabled) + + Text("Saved memory history is available from Manage.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + } +} + +struct MemoryBrowserSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var query = "" + @State private var items: [MemoryItem] = [] + @State private var isLoading = false + @State private var editingDraft: MemoryDraft? + @State private var deleteTarget: MemoryItem? + @State private var showDeleteAllConfirmation = false + + var body: some View { + @Bindable var appState = appState + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Memory History") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + Spacer() + Button { + editingDraft = MemoryDraft() + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + + Button(role: .destructive) { + showDeleteAllConfirmation = true + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Delete all memories") + .disabled(items.isEmpty) + Button("Done") { dismiss() } + } + + searchSection + + ScrollView { + memoryList + } + } + .padding(20) + .frame(width: 640, height: 540) + .task { await refresh() } + .onChange(of: appState.memoryRevision) { _, _ in + Task { await refresh() } + } + .sheet(item: $editingDraft) { draft in + MemoryEditorSheet(draft: draft) { + Task { await refresh() } + } + } + .alert("Delete Memory?", isPresented: Binding( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } } + )) { + Button("Delete", role: .destructive) { + if let target = deleteTarget { + Task { + await appState.deleteMemoryItem(id: target.id) + deleteTarget = nil + await refresh() + } + } + } + Button("Cancel", role: .cancel) { deleteTarget = nil } + } message: { + Text("This memory will be removed from future agent context.") + } + .alert("Delete All Memories?", isPresented: $showDeleteAllConfirmation) { + Button("Delete All", role: .destructive) { + Task { + await appState.deleteAllMemoryItems() + await refresh() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("All saved memories will be removed. This action cannot be undone.") + } + } + + private var searchSection: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search memory", text: $query) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await refresh() } } + Button { + Task { await refresh() } + } label: { + if isLoading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .buttonStyle(.borderless) + .help("Refresh") + .disabled(isLoading) + } + } + + @ViewBuilder + private var memoryList: some View { + if items.isEmpty && !isLoading { + VStack(alignment: .leading, spacing: 6) { + Text(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No memories" : "No matching memories") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + Text("Saved memories are stored locally in SwiftData and embedded on-device.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 18) + } else { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(items) { item in + memoryRow(item) + } + } + } + } + + private func memoryRow(_ item: MemoryItem) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: item.scope == "global" ? "globe" : "folder") + .font(.system(size: ClaudeTheme.size(13), weight: .medium)) + .foregroundStyle(ClaudeTheme.accent) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 5) { + Text(item.content) + .font(.system(size: ClaudeTheme.size(12))) + .foregroundStyle(ClaudeTheme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Text(item.kind.capitalized) + Text(item.scope.capitalized) + if let projectId = item.projectId { + Text(projectName(for: projectId)) + } + Text(item.updatedAt, style: .date) + } + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Button { + editingDraft = MemoryDraft(item: item) + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + .help("Edit memory") + + Button(role: .destructive) { + deleteTarget = item + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Delete memory") + } + .padding(10) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + private func refresh() async { + isLoading = true + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + items = await appState.allMemoryItems() + } else { + items = await appState.searchMemoryItems(query: trimmed, limit: 100).map(\.item) + } + isLoading = false + } + + private func projectName(for id: UUID) -> String { + appState.projects.first(where: { $0.id == id })?.name ?? id.uuidString + } +} + +struct MemoryDraft: Identifiable { + let id = UUID() + var existingId: String? + var content: String + var kind: String + var scope: String + var projectId: UUID? + + init() { + self.existingId = nil + self.content = "" + self.kind = "fact" + self.scope = "project" + self.projectId = nil + } + + init(item: MemoryItem) { + self.existingId = item.id + self.content = item.content + self.kind = item.kind + self.scope = item.scope + self.projectId = item.projectId + } +} + +struct MemoryEditorSheet: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + @State private var draft: MemoryDraft + /// Invoked after a successful save so a presenting list can refresh. + /// Deliberately argument-free: the draft is persisted here, inside the + /// sheet, so the struct never crosses an escaping async closure boundary. + private let onSaved: (() -> Void)? + + init(draft: MemoryDraft, onSaved: (() -> Void)? = nil) { + _draft = State(initialValue: draft) + self.onSaved = onSaved + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(draft.existingId == nil ? "Add Memory" : "Edit Memory") + .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) + + TextEditor(text: $draft.content) + .font(.system(size: ClaudeTheme.size(12))) + .frame(height: 110) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) + ) + + HStack(spacing: 12) { + Picker("Kind", selection: $draft.kind) { + Text("Fact").tag("fact") + Text("Preference").tag("preference") + Text("Decision").tag("decision") + } + .pickerStyle(.menu) + .frame(width: 180) + + Picker("Scope", selection: $draft.scope) { + Text("Project").tag("project") + Text("Global").tag("global") + } + .pickerStyle(.menu) + .frame(width: 180) + } + + if draft.scope == "project" { + Picker("Project", selection: projectSelection) { + Text("No project").tag("") + ForEach(appState.projects) { project in + Text(project.name).tag(project.id.uuidString) + } + } + .pickerStyle(.menu) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button("Save") { + Task { + await performSave() + dismiss() + } + } + .keyboardShortcut(.defaultAction) + .disabled(draft.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 460) + } + + /// Persists the draft directly through AppState. Kept inside the sheet — + /// rather than handed back via a `(MemoryDraft) async -> Void` callback — + /// so the struct is never passed through an escaping async closure. + private func performSave() async { + let draft = draft + if let id = draft.existingId { + _ = await appState.updateMemoryItem( + id: id, + content: draft.content, + projectId: draft.projectId, + kind: draft.kind, + scope: draft.scope + ) + } else { + _ = await appState.addMemoryItem( + content: draft.content, + projectId: draft.projectId, + kind: draft.kind, + scope: draft.scope + ) + } + onSaved?() + } + + private var projectSelection: Binding { + Binding( + get: { draft.projectId?.uuidString ?? "" }, + set: { raw in draft.projectId = UUID(uuidString: raw) } + ) + } +} + +// MARK: - Theme Picker Row + +struct ThemePickerRow: View { + let theme: AppTheme + let isSelected: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Circle() + .fill(theme.colors.accent) + .frame(width: 10, height: 10) + Text(theme.displayName) + .font(.system(size: ClaudeTheme.size(13))) + .foregroundStyle(.primary) + Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isHovering ? Color(NSColor.selectedContentBackgroundColor).opacity(0.5) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + } +} diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index f183116..8775288 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -422,890 +422,6 @@ struct GeneralSettingsTab: View { } } -// MARK: - Chat Settings Tab - -struct ChatSettingsTab: View { - @Environment(AppState.self) private var appState - @State private var isRefreshingAgentStatus = false - - var body: some View { - @Bindable var appState = appState - ScrollView { - VStack(alignment: .leading, spacing: 24) { - agentRuntimeSection - Divider() - modelSection - Divider() - summarizationSection - Divider() - permissionModeSection - Divider() - effortSection - Divider() - focusModeSection - Divider() - autoPreviewSection - Divider() - archiveSection - } - .padding(24) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - // MARK: - Agent Runtime Section - - private var agentRuntimeSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Agent Runtimes") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Spacer() - - Button { - Task { - isRefreshingAgentStatus = true - await appState.refreshAgentInstallations() - isRefreshingAgentStatus = false - } - } label: { - if isRefreshingAgentStatus { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .buttonStyle(.borderless) - .disabled(isRefreshingAgentStatus) - .help("Refresh installation status") - } - - VStack(spacing: 8) { - agentRuntimeRow( - title: "Claude Code", - installed: appState.claudeInstalled, - version: appState.claudeVersion, - path: appState.claudeBinaryPath - ) - agentRuntimeRow( - title: "Codex", - installed: appState.codexInstalled, - version: appState.codexVersion, - path: appState.codexBinaryPath - ) - } - } - } - - private func agentRuntimeRow( - title: String, - installed: Bool, - version: String?, - path: String? - ) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: installed ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) - .font(.system(size: ClaudeTheme.size(14))) - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(title) - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - Text(installed ? "Installed" : "Not found") - .font(.system(size: ClaudeTheme.size(11), weight: .medium)) - .foregroundStyle(installed ? ClaudeTheme.statusSuccess : ClaudeTheme.statusError) - if let version, !version.isEmpty { - Text(version) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - } - - Text(path ?? "No executable detected") - .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) - .foregroundStyle(path == nil ? .secondary : ClaudeTheme.textPrimary) - .lineLimit(2) - .truncationMode(.middle) - .textSelection(.enabled) - } - - Spacer(minLength: 0) - } - .padding(10) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) - ) - } - - // MARK: - Archive Section - - private var archiveSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - Text("Archive") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("Inactive chats are moved to the archive automatically. Pinned chats are never auto-archived.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - Toggle(isOn: $appState.autoArchiveEnabled) { - Text("Auto-archive inactive chats") - } - .toggleStyle(.switch) - .fixedSize() - - HStack(spacing: 10) { - Text("Archive after") - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(.secondary) - Stepper( - value: $appState.archiveRetentionDays, - in: 1...365 - ) { - Text("\(appState.archiveRetentionDays) day\(appState.archiveRetentionDays == 1 ? "" : "s") of inactivity") - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - } - .fixedSize() - } - .disabled(!appState.autoArchiveEnabled) - .opacity(appState.autoArchiveEnabled ? 1 : 0.5) - } - } - - // MARK: - Model Section - - private var modelSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Default Model") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("Used for new sessions. You can override the model per session from the toolbar.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - Picker("", selection: defaultModelKey) { - ForEach(appState.availableAgentModelSections(), id: \.id) { section in - Section(section.title) { - ForEach(section.models, id: \.key) { model in - Text(model.displayName).tag(model.key) - } - } - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - - Text(selectedDefaultModel.description) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - } - - private var defaultModelKey: Binding { - Binding( - get: { "\(appState.selectedAgentProvider.rawValue):\(appState.selectedModel)" }, - set: { key in - let parts = key.split(separator: ":", maxSplits: 1).map(String.init) - guard parts.count == 2, let provider = AgentProvider(rawValue: parts[0]) else { return } - appState.selectedAgentProvider = provider - appState.selectedModel = parts[1] - } - ) - } - - private var selectedDefaultModel: AgentModel { - appState.availableAgentModelSections() - .flatMap(\.models) - .first { $0.provider == appState.selectedAgentProvider && $0.id == appState.selectedModel } - ?? AgentModel( - provider: appState.selectedAgentProvider, - id: appState.selectedModel, - displayName: appState.modelDisplayLabel(appState.selectedModel, provider: appState.selectedAgentProvider), - description: AppState.modelDescription(appState.selectedModel, provider: appState.selectedAgentProvider) - ) - } - - // MARK: - Summarization Section - - private var summarizationSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - Text("Summarization Model") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("Used to generate short session titles. The default follows each thread's model.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - Picker("Provider", selection: $appState.summarizationProvider) { - ForEach(SummarizationProvider.availableCases) { provider in - Text(provider.displayName).tag(provider) - } - } - .pickerStyle(.menu) - .fixedSize() - .popoverTip(RxCodeTips.SummarizationModelTip(), arrowEdge: .trailing) - .onChange(of: appState.summarizationProvider) { _, newValue in - guard newValue == .openAI, appState.openAISummarizationModels.isEmpty else { return } - Task { await appState.refreshOpenAISummarizationModels() } - } - - switch appState.summarizationProvider { - case .selectedClient: - Text("Uses the model saved on the current thread.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - case .openAI: - openAISummarizationForm - case .appleFoundationModel: - appleFoundationModelStatus - } - } - } - - private var appleFoundationModelStatus: some View { - VStack(alignment: .leading, spacing: 6) { - Text("Runs on-device with Apple Intelligence. Private, free, and offline.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - if let reason = FoundationModelSummarizationService.unavailabilityReason { - Text(reason) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.statusError) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - private var openAISummarizationForm: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 10) { - settingsTextFieldRow( - label: "Endpoint", - text: $appState.openAISummarizationEndpoint, - prompt: AppState.defaultOpenAISummarizationEndpoint - ) - - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("API Key") - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(.secondary) - .frame(width: 84, alignment: .leading) - SecureField("sk-...", text: $appState.openAISummarizationAPIKey) - .textFieldStyle(.roundedBorder) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) - } - - HStack(spacing: 10) { - Picker("Model", selection: $appState.openAISummarizationModel) { - if !appState.openAISummarizationModel.isEmpty, - !appState.openAISummarizationModels.contains(appState.openAISummarizationModel) - { - Text(appState.openAISummarizationModel).tag(appState.openAISummarizationModel) - } - ForEach(appState.openAISummarizationModels, id: \.self) { model in - Text(model).tag(model) - } - if appState.openAISummarizationModels.isEmpty && appState.openAISummarizationModel.isEmpty { - Text("Fetch models first").tag("") - } - } - .pickerStyle(.menu) - .frame(width: 260, alignment: .leading) - - Button { - Task { await appState.refreshOpenAISummarizationModels() } - } label: { - if appState.isLoadingOpenAISummarizationModels { - ProgressView() - .controlSize(.small) - } else { - Label("Fetch Models", systemImage: "arrow.clockwise") - } - } - .disabled(appState.isLoadingOpenAISummarizationModels) - } - - if let error = appState.openAISummarizationModelsError { - Text(error) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.statusError) - .fixedSize(horizontal: false, vertical: true) - } - } - .onAppear { - guard appState.openAISummarizationModels.isEmpty else { return } - Task { await appState.refreshOpenAISummarizationModels() } - } - } - - private func settingsTextFieldRow(label: String, text: Binding, prompt: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(label) - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(.secondary) - .frame(width: 84, alignment: .leading) - TextField(prompt, text: text) - .textFieldStyle(.roundedBorder) - .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) - } - } - - // MARK: - Permission Mode Section - - private var permissionModeSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - Text("Default Permission Mode") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("Used for new sessions. You can override the permission mode per session from the toolbar.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - Picker("", selection: $appState.permissionMode) { - ForEach(PermissionMode.allCases, id: \.self) { mode in - Text(LocalizedStringKey(mode.displayName)).tag(mode) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - - Text(AppState.permissionModeDescription(appState.permissionMode)) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - } - - // MARK: - Effort Section - - private var effortSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - Text("Default Effort Level") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("Used for new sessions. You can override the effort level per session from the toolbar.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - Picker("", selection: $appState.selectedEffort) { - Text("Auto").tag("auto") - ForEach(AppState.availableEfforts, id: \.self) { effort in - Text(effortDisplayName(effort)).tag(effort) - } - } - .labelsHidden() - .pickerStyle(.menu) - .fixedSize() - - Text(AppState.effortDescription(appState.selectedEffort)) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - } - - // MARK: - Focus Mode Section - - private var focusModeSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - Text("Focus Mode") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("focus.mode.desc") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - Toggle(isOn: $appState.focusMode) { - Text("Enable Focus Mode") - } - .toggleStyle(.switch) - .fixedSize() - } - } - - // MARK: - Auto-Preview Attachments Section - - private var autoPreviewSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - Text("Auto-preview Attachments") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - - Text("auto.preview.desc") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 6) { - Toggle("URL links", isOn: $appState.autoPreviewSettings.url) - Toggle("File paths", isOn: $appState.autoPreviewSettings.filePath) - Toggle("Images", isOn: $appState.autoPreviewSettings.image) - Toggle("Long text (200+ characters)", isOn: $appState.autoPreviewSettings.longText) - } - .toggleStyle(.checkbox) - } - } - - private func effortDisplayName(_ effort: String) -> String { - switch effort { - case "low": return "Low" - case "medium": return "Medium" - case "high": return "High" - case "xhigh": return "Extra High" - case "max": return "Max" - default: return effort.capitalized - } - } -} - -// MARK: - Memory Settings Section - -struct MemorySettingsSection: View { - @Environment(AppState.self) private var appState - - @State private var editingDraft: MemoryDraft? - @State private var showMemoryBrowser = false - - var body: some View { - VStack(alignment: .leading, spacing: 18) { - controlsSection - } - .frame(maxWidth: .infinity, alignment: .leading) - .sheet(item: $editingDraft) { draft in - MemoryEditorSheet(draft: draft) - } - .sheet(isPresented: $showMemoryBrowser) { - MemoryBrowserSheet() - .environment(appState) - } - } - - private var controlsSection: some View { - @Bindable var appState = appState - return VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Memory") - .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - Spacer() - Button { - editingDraft = MemoryDraft() - } label: { - Label("Add", systemImage: "plus") - } - .help("Add memory") - Button { - showMemoryBrowser = true - } label: { - Label("Manage", systemImage: "list.bullet.rectangle") - } - .help("View and manage saved memories") - } - - Toggle(isOn: $appState.memoryEnabled) { - Text("Enable Memory") - } - .toggleStyle(.switch) - .fixedSize() - - Toggle(isOn: $appState.memoryAutoCreateEnabled) { - Text("Auto-create memories from completed chats") - } - .toggleStyle(.switch) - .fixedSize() - .disabled(!appState.memoryEnabled) - - Toggle(isOn: $appState.memoryInjectEnabled) { - Text("Include relevant memories in agent context") - } - .toggleStyle(.switch) - .fixedSize() - .disabled(!appState.memoryEnabled) - - Stepper(value: $appState.memoryMaxContextItems, in: 1...12) { - Text("Context memories: \(appState.memoryMaxContextItems)") - .font(.system(size: ClaudeTheme.size(12))) - } - .fixedSize() - .disabled(!appState.memoryEnabled || !appState.memoryInjectEnabled) - - Text("Saved memory history is available from Manage.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - } -} - -private struct MemoryBrowserSheet: View { - @Environment(AppState.self) private var appState - @Environment(\.dismiss) private var dismiss - - @State private var query = "" - @State private var items: [MemoryItem] = [] - @State private var isLoading = false - @State private var editingDraft: MemoryDraft? - @State private var deleteTarget: MemoryItem? - @State private var showDeleteAllConfirmation = false - - var body: some View { - @Bindable var appState = appState - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Memory History") - .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) - Spacer() - Button { - editingDraft = MemoryDraft() - } label: { - Image(systemName: "plus") - } - .buttonStyle(.borderless) - - Button(role: .destructive) { - showDeleteAllConfirmation = true - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Delete all memories") - .disabled(items.isEmpty) - Button("Done") { dismiss() } - } - - searchSection - - ScrollView { - memoryList - } - } - .padding(20) - .frame(width: 640, height: 540) - .task { await refresh() } - .onChange(of: appState.memoryRevision) { _, _ in - Task { await refresh() } - } - .sheet(item: $editingDraft) { draft in - MemoryEditorSheet(draft: draft) { - Task { await refresh() } - } - } - .alert("Delete Memory?", isPresented: Binding( - get: { deleteTarget != nil }, - set: { if !$0 { deleteTarget = nil } } - )) { - Button("Delete", role: .destructive) { - if let target = deleteTarget { - Task { - await appState.deleteMemoryItem(id: target.id) - deleteTarget = nil - await refresh() - } - } - } - Button("Cancel", role: .cancel) { deleteTarget = nil } - } message: { - Text("This memory will be removed from future agent context.") - } - .alert("Delete All Memories?", isPresented: $showDeleteAllConfirmation) { - Button("Delete All", role: .destructive) { - Task { - await appState.deleteAllMemoryItems() - await refresh() - } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("All saved memories will be removed. This action cannot be undone.") - } - } - - private var searchSection: some View { - HStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Search memory", text: $query) - .textFieldStyle(.roundedBorder) - .onSubmit { Task { await refresh() } } - Button { - Task { await refresh() } - } label: { - if isLoading { - ProgressView().controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .buttonStyle(.borderless) - .help("Refresh") - .disabled(isLoading) - } - } - - @ViewBuilder - private var memoryList: some View { - if items.isEmpty && !isLoading { - VStack(alignment: .leading, spacing: 6) { - Text(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No memories" : "No matching memories") - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - Text("Saved memories are stored locally in SwiftData and embedded on-device.") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 18) - } else { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(items) { item in - memoryRow(item) - } - } - } - } - - private func memoryRow(_ item: MemoryItem) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: item.scope == "global" ? "globe" : "folder") - .font(.system(size: ClaudeTheme.size(13), weight: .medium)) - .foregroundStyle(ClaudeTheme.accent) - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 5) { - Text(item.content) - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(ClaudeTheme.textPrimary) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 8) { - Text(item.kind.capitalized) - Text(item.scope.capitalized) - if let projectId = item.projectId { - Text(projectName(for: projectId)) - } - Text(item.updatedAt, style: .date) - } - .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - - Button { - editingDraft = MemoryDraft(item: item) - } label: { - Image(systemName: "pencil") - } - .buttonStyle(.borderless) - .help("Edit memory") - - Button(role: .destructive) { - deleteTarget = item - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .help("Delete memory") - } - .padding(10) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) - ) - } - - private func refresh() async { - isLoading = true - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - items = await appState.allMemoryItems() - } else { - items = await appState.searchMemoryItems(query: trimmed, limit: 100).map(\.item) - } - isLoading = false - } - - private func projectName(for id: UUID) -> String { - appState.projects.first(where: { $0.id == id })?.name ?? id.uuidString - } -} - -private struct MemoryDraft: Identifiable { - let id = UUID() - var existingId: String? - var content: String - var kind: String - var scope: String - var projectId: UUID? - - init() { - self.existingId = nil - self.content = "" - self.kind = "fact" - self.scope = "project" - self.projectId = nil - } - - init(item: MemoryItem) { - self.existingId = item.id - self.content = item.content - self.kind = item.kind - self.scope = item.scope - self.projectId = item.projectId - } -} - -private struct MemoryEditorSheet: View { - @Environment(AppState.self) private var appState - @Environment(\.dismiss) private var dismiss - - @State private var draft: MemoryDraft - /// Invoked after a successful save so a presenting list can refresh. - /// Deliberately argument-free: the draft is persisted here, inside the - /// sheet, so the struct never crosses an escaping async closure boundary. - private let onSaved: (() -> Void)? - - init(draft: MemoryDraft, onSaved: (() -> Void)? = nil) { - _draft = State(initialValue: draft) - self.onSaved = onSaved - } - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text(draft.existingId == nil ? "Add Memory" : "Edit Memory") - .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) - - TextEditor(text: $draft.content) - .font(.system(size: ClaudeTheme.size(12))) - .frame(height: 110) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color(NSColor.separatorColor), lineWidth: 1) - ) - - HStack(spacing: 12) { - Picker("Kind", selection: $draft.kind) { - Text("Fact").tag("fact") - Text("Preference").tag("preference") - Text("Decision").tag("decision") - } - .pickerStyle(.menu) - .frame(width: 180) - - Picker("Scope", selection: $draft.scope) { - Text("Project").tag("project") - Text("Global").tag("global") - } - .pickerStyle(.menu) - .frame(width: 180) - } - - if draft.scope == "project" { - Picker("Project", selection: projectSelection) { - Text("No project").tag("") - ForEach(appState.projects) { project in - Text(project.name).tag(project.id.uuidString) - } - } - .pickerStyle(.menu) - } - - HStack { - Spacer() - Button("Cancel") { dismiss() } - Button("Save") { - Task { - await performSave() - dismiss() - } - } - .keyboardShortcut(.defaultAction) - .disabled(draft.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(20) - .frame(width: 460) - } - - /// Persists the draft directly through AppState. Kept inside the sheet — - /// rather than handed back via a `(MemoryDraft) async -> Void` callback — - /// so the struct is never passed through an escaping async closure. - private func performSave() async { - let draft = draft - if let id = draft.existingId { - _ = await appState.updateMemoryItem( - id: id, - content: draft.content, - projectId: draft.projectId, - kind: draft.kind, - scope: draft.scope - ) - } else { - _ = await appState.addMemoryItem( - content: draft.content, - projectId: draft.projectId, - kind: draft.kind, - scope: draft.scope - ) - } - onSaved?() - } - - private var projectSelection: Binding { - Binding( - get: { draft.projectId?.uuidString ?? "" }, - set: { raw in draft.projectId = UUID(uuidString: raw) } - ) - } -} - -// MARK: - Theme Picker Row - -private struct ThemePickerRow: View { - let theme: AppTheme - let isSelected: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Circle() - .fill(theme.colors.accent) - .frame(width: 10, height: 10) - Text(theme.displayName) - .font(.system(size: ClaudeTheme.size(13))) - .foregroundStyle(.primary) - Spacer(minLength: 0) - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .frame(maxWidth: .infinity, alignment: .leading) - .background(isHovering ? Color(NSColor.selectedContentBackgroundColor).opacity(0.5) : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - } -} - #Preview { SettingsView() .environment(AppState()) diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift new file mode 100644 index 0000000..07572fb --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -0,0 +1,533 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeChatKit +import RxCodeSync +import SwiftUI +import UIKit +import os.log +extension MobileAppState { + // MARK: - Inbound events + + func handle(_ event: RelayClient.Event) { + switch event { + case .stateChanged(let state): + logger.info("[Relay] connection state changed: \(String(describing: state), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") + let previous = connectionState + connectionState = state + triggerConnectionFeedback(from: previous, to: state) + if case .connected = state, isPaired { + Task { await self.requestSnapshot(reason: "relay_connected") } + } + case .deliveryFailed(let toHex): + logger.warning("[Relay] delivery failed to desktopKey=\(String(toHex.prefix(12)), privacy: .public)") + case .inbound(let inbound): + handleInbound(inbound) + } + } + + func handleInbound(_ inbound: RelayClient.Inbound) { + switch inbound.payload { + case .pairAck(let ack): + pairingTimeoutTask?.cancel() + pairingTimeoutTask = nil + if ack.accepted { + upsertPairedDesktop( + PairedDesktop( + pubkeyHex: inbound.fromHex, + displayName: ack.desktopName, + pairedAt: .now, + lastSeen: .now + ) + ) + setActiveDesktop(pubkeyHex: inbound.fromHex) + pairingStatus = .idle + logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + MobileHaptics.connected() + savePairedDesktops() + Task { + await self.requestSnapshot() + await self.reportAPNsTokenIfPending() + } + } else { + failPairing("Your Mac declined the pairing request.") + } + case .unpair: + guard let desktop = pairedDesktops.first(where: { $0.pubkeyHex == inbound.fromHex }) else { return } + Task { await self.removePairedDesktopAfterRemoteUnpair(desktop) } + case .snapshot(let snap): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "snapshot") else { return } + let profileProjectCount = snap.runProfiles?.count ?? 0 + let profileTotal = snap.runProfiles?.reduce(0) { $0 + $1.profiles.count } ?? 0 + logger.info("[MobileSync] applying snapshot projects=\(snap.projects.count, privacy: .public) sessions=\(snap.sessions.count, privacy: .public) runProfileProjects=\(profileProjectCount, privacy: .public) runProfileTotal=\(profileTotal, privacy: .public) runTasks=\(snap.runTasks?.count ?? 0, privacy: .public) from desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") + projects = snap.projects + sessions = snap.sessions + branchBriefings = snap.branchBriefings ?? [] + threadSummaries = snap.threadSummaries ?? [] + desktopSettings = snap.settings + desktopUsage = snap.usage + desktopHostMetrics = snap.hostMetrics + desktopWebProxy = snap.webProxy + if let webProxy = snap.webProxy { + logger.info("[WebBrowserSync] snapshot web proxy host=\(webProxy.host, privacy: .public) port=\(webProxy.port, privacy: .public)") + } else { + logger.warning("[WebBrowserSync] snapshot missing web proxy info") + } + if let runProfiles = snap.runProfiles { + runProfilesByProject = Dictionary( + uniqueKeysWithValues: runProfiles.map { ($0.projectId, $0.profiles) } + ) + } + if let tasks = snap.runTasks { + runTasks = tasks.sorted { $0.startedAt > $1.startedAt } + } + if let branches = snap.projectBranches { + projectBranches = Dictionary(uniqueKeysWithValues: branches.map { ($0.projectId, $0.currentBranch) }) + availableBranchesByProject = Dictionary( + uniqueKeysWithValues: branches.compactMap { info -> (UUID, [String])? in + guard let list = info.availableBranches else { return nil } + return (info.projectId, list) + } + ) + } + if let active = snap.activeSessionID { + if let messages = snap.activeSessionMessages { + // The snapshot carries only the most recent page; replacing + // the window resets paging to that page. + messagesBySession[active] = messages + if snap.activeSessionHasMore == true { + sessionsWithMoreMessages.insert(active) + } else { + sessionsWithMoreMessages.remove(active) + } + loadingMoreSessions.remove(active) + } else if messagesBySession[active] == nil { + messagesBySession[active] = [] + } + activeSessionID = active + } + refreshWidgetData() + case .moreMessages(let page): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "more_messages") else { return } + applyMoreMessages(page) + case .sessionUpdate(let update): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "session_update") else { return } + applySessionUpdate(update) + refreshWidgetData() + case .permissionRequest(let req): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "permission_request") else { return } + pendingPermission = req + MobileHaptics.attentionNeeded() + case .questionQueue(let queue): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "question_queue") else { return } + let grew = queue.questions.count > pendingQuestions.count + pendingQuestions = queue.questions + if grew { MobileHaptics.attentionNeeded() } + case .notification: + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "notification") else { return } + // Foreground notifications arriving over WS — iOS won't show a + // banner automatically; UI surfaces these in a toast/badge. + break + case .searchResults(let results): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "search_results") else { return } + guard let pending = pendingSearchID, results.clientRequestID == pending else { return } + searchProjectIDs = results.projectIDs + searchThreadHits = results.threadHits + isSearching = false + case .threadChangesResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "thread_changes_result") else { return } + guard let pending = pendingThreadChangesID, result.clientRequestID == pending else { return } + pendingThreadChangesID = nil + isLoadingThreadChanges = false + threadChanges = result + case .branchOpResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "branch_op_result") else { return } + inFlightBranchOps.remove(result.clientRequestID) + if !result.ok { + lastBranchOpError = result.errorMessage ?? "Branch operation failed." + } + case .folderTreeResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "folder_tree_result") else { return } + guard pendingFolderTreeRequestID == result.clientRequestID else { return } + pendingFolderTreeRequestID = nil + remoteFolderIsLoading = false + if result.ok, let root = result.root { + remoteFolderRoot = root + remoteFolderError = nil + } else { + remoteFolderError = result.errorMessage ?? "Failed to load folders." + } + case .createProjectResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "create_project_result") else { return } + guard pendingCreateProjectRequestID == result.clientRequestID else { return } + pendingCreateProjectRequestID = nil + remoteProjectCreateInFlight = false + if result.ok, let project = result.project { + if !projects.contains(where: { $0.id == project.id }) { + projects.append(project) + } + lastCreatedProjectID = project.id + remoteProjectCreateError = nil + Task { await self.requestSnapshot() } + } else { + remoteProjectCreateError = result.errorMessage ?? "Failed to add project." + } + case .runProfileResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_profile_result") else { return } + logger.info("[RunProfiles] received result id=\(result.clientRequestID.uuidString, privacy: .public) ok=\(result.ok, privacy: .public) project=\(result.projectID.uuidString, privacy: .public) profiles=\(result.profiles?.count ?? 0, privacy: .public) task=\(result.task?.taskId.uuidString ?? "", privacy: .public) error=\(result.errorMessage ?? "", privacy: .public)") + applyRunProfileResult(result) + case .runTaskUpdate(let update): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_task_update") else { return } + upsertRunTask(update.task) + case .skillCatalogResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_catalog_result") else { return } + applySkillCatalogResult(result) + case .skillMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_mutation_result") else { return } + applySkillMutationResult(result) + case .skillSourceMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_source_mutation_result") else { return } + applySkillSourceMutationResult(result) + case .acpRegistryResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_registry_result") else { return } + applyACPRegistryResult(result) + case .acpMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_mutation_result") else { return } + applyACPMutationResult(result) + case .mcpConfigResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_config_result") else { return } + applyMCPConfigResult(result) + case .mcpMutationResult(let result): + guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_mutation_result") else { return } + applyMCPMutationResult(result) + case .ping: + guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } + Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } + default: + break + } + } + + func applyRunProfileResult(_ result: RunProfileResultPayload) { + inFlightRunProfileRequests.remove(result.clientRequestID) + if let profiles = result.profiles { + runProfilesByProject[result.projectID] = profiles + logger.info("[RunProfiles] applied result profiles project=\(result.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") + } + if let task = result.task { + upsertRunTask(task) + } + if result.ok { + lastRunProfileError = nil + Task { await self.requestSnapshot() } + } else { + lastRunProfileError = result.errorMessage ?? "Run profile operation failed." + } + } + + func applySkillCatalogResult(_ result: SkillCatalogResultPayload) { + guard pendingSkillCatalogRequestID == result.clientRequestID else { return } + pendingSkillCatalogRequestID = nil + skillCatalogLoading = false + if result.ok { + skillCatalog = result.plugins + skillSources = result.sources + skillCatalogError = nil + } else { + skillCatalogError = result.errorMessage ?? "Failed to load skills." + } + } + + func applySkillMutationResult(_ result: SkillMutationResultPayload) { + inFlightSkillMutations.remove(result.pluginID) + skillCatalog = result.plugins + skillSources = result.sources + if result.ok { + lastSkillError = nil + } else { + lastSkillError = result.errorMessage ?? "Skill operation failed." + } + } + + func applySkillSourceMutationResult(_ result: SkillSourceMutationResultPayload) { + if let key = skillSourceMutationKeys.removeValue(forKey: result.clientRequestID) { + inFlightSkillSourceMutations.remove(key) + } + if let sourceID = result.sourceID { + inFlightSkillSourceMutations.remove(sourceID) + } + skillCatalog = result.plugins + skillSources = result.sources + if result.ok { + lastSkillError = nil + } else { + lastSkillError = result.errorMessage ?? "Skill source operation failed." + } + } + + func applyACPRegistryResult(_ result: ACPRegistryResultPayload) { + guard pendingACPRegistryRequestID == result.clientRequestID else { return } + pendingACPRegistryRequestID = nil + acpRegistryLoading = false + if result.ok { + acpRegistryAgents = result.registryAgents + acpInstalledClients = result.installedClients + acpRegistryError = nil + } else { + acpRegistryError = result.errorMessage ?? "Failed to load the agent registry." + } + } + + func applyACPMutationResult(_ result: ACPMutationResultPayload) { + if let key = acpMutationKeys.removeValue(forKey: result.clientRequestID) { + inFlightACPMutations.remove(key) + } + acpRegistryAgents = result.registryAgents + acpInstalledClients = result.installedClients + if result.ok { + lastACPError = nil + } else { + lastACPError = result.errorMessage ?? "Agent operation failed." + } + } + + func applyMCPConfigResult(_ result: MCPConfigResultPayload) { + guard pendingMCPConfigRequestID == result.clientRequestID else { return } + pendingMCPConfigRequestID = nil + mcpConfigLoading = false + if result.ok { + mcpServers = result.servers + mcpConfigError = nil + } else { + mcpConfigError = result.errorMessage ?? "Failed to load MCP servers." + } + } + + func applyMCPMutationResult(_ result: MCPMutationResultPayload) { + inFlightMCPMutations.remove(result.serverName) + mcpServers = result.servers + if result.ok { + lastMCPError = nil + } else { + lastMCPError = result.errorMessage ?? "MCP operation failed." + } + } + + func upsertRunTask(_ task: MobileRunTaskSnapshot) { + if let idx = runTasks.firstIndex(where: { $0.taskId == task.taskId }) { + runTasks[idx] = task + } else { + runTasks.insert(task, at: 0) + } + runTasks.sort { $0.startedAt > $1.startedAt } + } + + func applySessionUpdate(_ update: SessionUpdatePayload) { + if let previous = update.previousSessionID, previous != update.sessionID { + if let carried = messagesBySession.removeValue(forKey: previous) { + if let existing = messagesBySession[update.sessionID], !existing.isEmpty { + // The new session id already accumulated live messages + // before the redirect landed. Prepend the carried history, + // deduped by id, so the older messages aren't dropped. + let existingIDs = Set(existing.map(\.id)) + messagesBySession[update.sessionID] = + carried.filter { !existingIDs.contains($0.id) } + existing + } else { + messagesBySession[update.sessionID] = carried + } + } + if sessionsWithMoreMessages.remove(previous) != nil { + sessionsWithMoreMessages.insert(update.sessionID) + } + if loadingMoreSessions.remove(previous) != nil { + loadingMoreSessions.insert(update.sessionID) + } + if activeSessionID == previous { + activeSessionID = update.sessionID + } + sessions.removeAll { $0.id == previous } + } + + if let summary = update.summary { + upsertSessionSummary(summary) + } else if let isStreaming = update.isStreaming { + updateSessionStreamingFlag(sessionID: update.sessionID, isStreaming: isStreaming) + } + + if let isThinking = update.isThinking { + setSessionThinking(sessionID: update.sessionID, isThinking: isThinking) + } + // A session that is no longer streaming cannot still be thinking. + if update.isStreaming == false { + thinkingSessions.remove(update.sessionID) + } + + switch update.kind { + case .messageAppended: + if let m = update.message { + messagesBySession[update.sessionID, default: []].append(m) + } + case .messageUpdated: + if let m = update.message, + var list = messagesBySession[update.sessionID], + let idx = list.firstIndex(where: { $0.id == m.id }) { + list[idx] = m + messagesBySession[update.sessionID] = list + } + case .streamingFinished: + thinkingSessions.remove(update.sessionID) + // Soft success cue, but only when the user is actually looking at + // (or last looked at) the session that just finished. Avoids + // buzzing on background-session completions. + if update.sessionID == activeSessionID { + MobileHaptics.streamFinished() + } + case .streamingStarted, .statusChanged: + // Surface as a flag on the relevant session row. + break + } + } + + func upsertSessionSummary(_ summary: SessionSummary) { + if let index = sessions.firstIndex(where: { $0.id == summary.id }) { + sessions[index] = summary + } else { + sessions.append(summary) + } + sessions.sort { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } + return lhs.updatedAt > rhs.updatedAt + } + } + + func updateSessionStreamingFlag(sessionID: String, isStreaming: Bool) { + guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return } + let current = sessions[index] + sessions[index] = SessionSummary( + id: current.id, + projectId: current.projectId, + title: current.title, + updatedAt: current.updatedAt, + isPinned: current.isPinned, + isArchived: current.isArchived, + isStreaming: isStreaming, + attention: current.attention, + progress: current.progress, + todos: current.todos, + queuedMessages: current.queuedMessages, + hasUncheckedCompletion: current.hasUncheckedCompletion + ) + } + + func setSessionThinking(sessionID: String, isThinking: Bool) { + if isThinking { + thinkingSessions.insert(sessionID) + } else { + thinkingSessions.remove(sessionID) + } + } + + func refreshFromDesktop(reason: String) async { + await requestSnapshot(reason: reason) + } + + func requestSnapshot(reason: String = "manual") async { + guard isPaired else { + logger.info("[MobileSync] snapshot request skipped reason=\(reason, privacy: .public): mobile is not paired") + return + } + let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) + do { + try await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) + logger.info("[MobileSync] requested snapshot reason=\(reason, privacy: .public) activeSession=\(self.activeSessionID ?? "", privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") + } catch { + logger.error("[MobileSync] snapshot request failed reason=\(reason, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func failPairing(_ message: String) { + pairingStatus = .failed(message) + MobileHaptics.connectionError() + } + + func triggerConnectionFeedback( + from previous: RelayClient.ConnectionState, + to next: RelayClient.ConnectionState + ) { + guard previous != next else { return } + guard isPaired, pairingStatus != .inProgress else { return } + + switch next { + case .connected: + if case .reconnecting = previous { + MobileHaptics.connected() + } + case .reconnecting: + if previous == .connected { + MobileHaptics.connectionError() + } + case .disconnected: + if previous == .connected { + MobileHaptics.connectionError() + } + case .connecting: + break + } + } + + func reportAPNsTokenIfPending() async { + guard !pairedDesktops.isEmpty else { + logger.info("[APNs] token report pending: mobile is not paired") + return + } + guard let tokenHex = apnsTokenHex else { + logger.info("[APNs] token report pending: no APNs token yet") + return + } + guard let env = apnsEnvironment else { + logger.info("[APNs] token report pending: no APNs environment yet") + return + } + let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) + for desktop in pairedDesktops { + do { + try await client.send(.apnsToken(payload), toHex: desktop.pubkeyHex) + logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + } catch { + logger.error("[APNs] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + + func applySettingsUpdateLocally(_ update: MobileSettingsUpdatePayload) { + guard let current = desktopSettings else { return } + // Optimistically reflect a provider change, resolving its display name + // from the synced options so the picker label updates immediately. + let summarizationProvider = update.summarizationProvider ?? current.summarizationProvider + let summarizationProviderDisplayName = update.summarizationProvider.flatMap { raw in + current.availableSummarizationProviders?.first(where: { $0.id == raw })?.displayName + } ?? current.summarizationProviderDisplayName + desktopSettings = MobileSettingsSnapshot( + selectedAgentProvider: update.selectedAgentProvider ?? current.selectedAgentProvider, + selectedModel: update.selectedModel ?? current.selectedModel, + selectedACPClientId: update.selectedACPClientId ?? current.selectedACPClientId, + selectedEffort: update.selectedEffort ?? current.selectedEffort, + permissionMode: update.permissionMode ?? current.permissionMode, + summarizationProvider: summarizationProvider, + summarizationProviderDisplayName: summarizationProviderDisplayName, + openAISummarizationEndpoint: current.openAISummarizationEndpoint, + openAISummarizationModel: update.openAISummarizationModel ?? current.openAISummarizationModel, + notificationsEnabled: update.notificationsEnabled ?? current.notificationsEnabled, + focusMode: update.focusMode ?? current.focusMode, + autoArchiveEnabled: update.autoArchiveEnabled ?? current.autoArchiveEnabled, + archiveRetentionDays: update.archiveRetentionDays ?? current.archiveRetentionDays, + autoPreviewSettings: update.autoPreviewSettings ?? current.autoPreviewSettings, + availableEfforts: current.availableEfforts, + availableModels: current.availableModels, + modelSections: current.modelSections, + availableSummarizationProviders: current.availableSummarizationProviders, + openAISummarizationModels: current.openAISummarizationModels + ) + } +} diff --git a/RxCodeMobile/State/MobileAppState+Intents.swift b/RxCodeMobile/State/MobileAppState+Intents.swift new file mode 100644 index 0000000..f0f38b7 --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Intents.swift @@ -0,0 +1,282 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeChatKit +import RxCodeSync +import SwiftUI +import UIKit +import os.log +extension MobileAppState { + func sendUserMessage(_ text: String, sessionID: String) async { + guard isPaired else { return } + let payload = UserMessagePayload(sessionID: sessionID, text: text) + try? await client.send(.userMessage(payload), toHex: pairedDesktopPubkey) + } + + func cancelStream(sessionID: String) async { + guard isPaired else { return } + let payload = CancelStreamPayload(sessionID: sessionID) + try? await client.send(.cancelStream(payload), toHex: pairedDesktopPubkey) + } + + func removeQueuedMessage(sessionID: String, queuedID: UUID) async { + guard isPaired else { return } + let payload = RemoveQueuedMessagePayload(sessionID: sessionID, queuedMessageID: queuedID) + try? await client.send(.removeQueuedMessage(payload), toHex: pairedDesktopPubkey) + } + + /// True iff the desktop reports the given session as actively streaming. + func isSessionStreaming(_ sessionID: String) -> Bool { + sessions.first(where: { $0.id == sessionID })?.isStreaming ?? false + } + + /// True iff the desktop reports the given session as currently producing + /// reasoning/thinking tokens. + func isSessionThinking(_ sessionID: String) -> Bool { + thinkingSessions.contains(sessionID) + } + + /// Whether the given session has messages older than the loaded window. + func hasMoreMessages(sessionID: String) -> Bool { + sessionsWithMoreMessages.contains(sessionID) + } + + /// Whether an older page is currently being fetched for the given session. + func isLoadingMoreMessages(sessionID: String) -> Bool { + loadingMoreSessions.contains(sessionID) + } + + /// Mirror of the desktop's per-session queue, surfaced via `SessionSummary`. + func queuedMessages(sessionID: String) -> [QueuedUserMessage] { + sessions.first(where: { $0.id == sessionID })?.queuedMessages ?? [] + } + + /// Ask the desktop to create a new thread. The per-thread agent config + /// (model, permission mode, plan mode) travels in the request so the thread + /// is created with exactly the chosen config — fixing the bug where a + /// non-default model was dropped in favor of the project default. ACP client + /// and effort have no mobile picker, so they ride along from the last synced + /// desktop settings. + func requestNewSession( + projectID: UUID, + initialText: String? = nil, + agentProvider: AgentProvider? = nil, + model: String? = nil, + permissionMode: PermissionMode? = nil, + planMode: Bool = false + ) async { + guard isPaired else { return } + let payload = NewSessionRequestPayload( + projectID: projectID, + initialText: initialText, + selectedAgentProvider: agentProvider, + selectedModel: model, + selectedACPClientId: desktopSettings?.selectedACPClientId, + selectedEffort: desktopSettings?.selectedEffort, + permissionMode: permissionMode, + planMode: planMode + ) + try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) + } + + func requestRemoteFolder(path: String? = nil) async { + guard isPaired else { return } + let request = FolderTreeRequestPayload(path: path, depth: 1) + pendingFolderTreeRequestID = request.clientRequestID + remoteFolderIsLoading = true + remoteFolderError = nil + do { + try await client.send(.folderTreeRequest(request), toHex: pairedDesktopPubkey) + } catch { + pendingFolderTreeRequestID = nil + remoteFolderIsLoading = false + remoteFolderError = error.localizedDescription + } + } + + func createProjectFromRemoteFolder(path: String) async { + guard isPaired else { return } + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let request = CreateProjectRequestPayload(path: trimmed) + pendingCreateProjectRequestID = request.clientRequestID + remoteProjectCreateInFlight = true + remoteProjectCreateError = nil + do { + try await client.send(.createProjectRequest(request), toHex: pairedDesktopPubkey) + } catch { + pendingCreateProjectRequestID = nil + remoteProjectCreateInFlight = false + remoteProjectCreateError = error.localizedDescription + } + } + + // MARK: - Plan mode + + /// Plan cards for a session, derived live from the synced messages using the + /// shared `PlanLogic` — the same `ExitPlanMode` detection the desktop chat + /// uses. Superseded plans (re-emitted within the same turn) are dropped so + /// only actionable cards surface. + func pendingPlans(sessionID: String) -> [PendingPlan] { + let messages = messagesBySession[sessionID] ?? [] + var plans: [PendingPlan] = [] + for message in messages { + for block in message.blocks { + guard let toolCall = block.toolCall, + PlanLogic.isExitPlanMode(toolCall) else { continue } + if PlanLogic.isSupersededExitPlanMode( + toolCall: toolCall, in: message, allMessages: messages + ) { continue } + let markdown = PlanLogic.renderMarkdown(for: toolCall, in: message) ?? "" + let decided = PlanLogic.isPlanDecided(toolCall) + plans.append(PendingPlan( + toolCallId: toolCall.id, + markdown: markdown, + isStreaming: !decided && markdown.isEmpty && message.isStreaming, + isDecided: decided, + decisionSummary: decided ? toolCall.result : nil + )) + } + } + return plans + } + + /// Send the user's plan decision to the desktop, which resolves the CLI + /// `ExitPlanMode` hook via `respondToPlanDecision`. No optimistic mutation — + /// the desktop broadcasts the updated `toolCall.result` back through the + /// normal session-update sync, so the banner and chat reconcile from that. + func respondToPlanDecision( + toolUseID: String, + sessionID: String, + action: PlanDecisionAction + ) async { + guard isPaired else { return } + let payload = PlanDecisionPayload( + toolUseID: toolUseID, + sessionID: sessionID, + decision: action + ) + try? await client.send(.planDecision(payload), toHex: pairedDesktopPubkey) + } + + // MARK: - Thread lifecycle actions + + /// Rename a thread. The local title is updated optimistically; the desktop + /// confirms via the next snapshot / session update. + func renameThread(sessionID: String, title: String) async { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + replaceSession(sessionID: sessionID) { current in + SessionSummary( + id: current.id, + projectId: current.projectId, + title: trimmed, + updatedAt: current.updatedAt, + isPinned: current.isPinned, + isArchived: current.isArchived, + isStreaming: current.isStreaming, + attention: current.attention, + progress: current.progress, + queuedMessages: current.queuedMessages + ) + } + await sendThreadAction(sessionID: sessionID, action: .rename, newTitle: trimmed) + } + + /// Archive a thread. Optimistically flips `isArchived` so the row drops out + /// of the active list right away. + func archiveThread(sessionID: String) async { + replaceSession(sessionID: sessionID) { current in + SessionSummary( + id: current.id, + projectId: current.projectId, + title: current.title, + updatedAt: current.updatedAt, + isPinned: current.isPinned, + isArchived: true, + isStreaming: current.isStreaming, + attention: current.attention, + progress: current.progress, + queuedMessages: current.queuedMessages + ) + } + await sendThreadAction(sessionID: sessionID, action: .archive) + } + + /// Delete a thread. Optimistically drops it from local state. + func deleteThread(sessionID: String) async { + sessions.removeAll { $0.id == sessionID } + messagesBySession.removeValue(forKey: sessionID) + sessionsWithMoreMessages.remove(sessionID) + loadingMoreSessions.remove(sessionID) + if activeSessionID == sessionID { activeSessionID = nil } + await sendThreadAction(sessionID: sessionID, action: .delete) + } + + func replaceSession(sessionID: String, _ transform: (SessionSummary) -> SessionSummary) { + guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return } + sessions[index] = transform(sessions[index]) + } + + func sendThreadAction( + sessionID: String, + action: ThreadActionRequestPayload.Action, + newTitle: String? = nil + ) async { + guard isPaired else { + logger.error("[ThreadAction] not paired — dropping action=\(action.rawValue, privacy: .public)") + return + } + let payload = ThreadActionRequestPayload( + sessionID: sessionID, + action: action, + newTitle: newTitle + ) + do { + try await client.send(.threadActionRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[ThreadAction] sent action=\(action.rawValue, privacy: .public) thread=\(sessionID, privacy: .public)") + } catch { + logger.error("[ThreadAction] send failed action=\(action.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + /// Tell the desktop to switch the project to an existing local branch. + /// Returns immediately; the eventual snapshot broadcast carries the new + /// `currentBranch` and any error surfaces via `lastBranchOpError`. + func switchProjectBranch(projectID: UUID, branch: String) async { + guard isPaired else { return } + let request = BranchOpRequestPayload( + projectID: projectID, + operation: .switchExisting, + branch: branch + ) + inFlightBranchOps.insert(request.clientRequestID) + try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) + } + + /// Tell the desktop to create a new branch + worktree off the current + /// branch. The desktop parks the worktree against the project so the next + /// new-thread request for this project spawns into it. + func createProjectBranch(projectID: UUID, branch: String) async { + guard isPaired else { return } + let request = BranchOpRequestPayload( + projectID: projectID, + operation: .createNew, + branch: branch + ) + inFlightBranchOps.insert(request.clientRequestID) + try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) + } + + func clearBranchOpError() { + lastBranchOpError = nil + } + + func subscribe(to sessionID: String?) async { + activeSessionID = sessionID + guard isPaired else { return } + let payload = SubscribeSessionPayload(sessionID: sessionID) + try? await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) + } +} diff --git a/RxCodeMobile/State/MobileAppState+Pairing.swift b/RxCodeMobile/State/MobileAppState+Pairing.swift new file mode 100644 index 0000000..64b4779 --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Pairing.swift @@ -0,0 +1,117 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeChatKit +import RxCodeSync +import SwiftUI +import UIKit +import os.log +extension MobileAppState { + func pair(with token: PairingToken, displayName: String) async { + guard !token.isExpired, + let desktopKey = token.desktopPublicKey else { + failPairing("Invalid or expired pairing code.") + return + } + pairingStatus = .inProgress + let desktopHex = token.desktopPubkeyHex + logger.info("pairing with relayURL=\(token.relayURL, privacy: .public)") + // Persist the relay URL we just learned from the QR. + if let url = URL(string: token.relayURL) { + await updateRelayForPairingIfNeeded(url) + } else { + logger.error("pairing token has invalid relayURL=\(token.relayURL, privacy: .public)") + } + if !clientStarted { + await startClient() + } + try? await client.addPeer(desktopHex) + guard await waitForRelayConnection() else { + logger.error("pairing relay connection timed out relay=\(self.relayURL.absoluteString, privacy: .public)") + failPairing("Couldn't connect to the relay from the QR code. Check the relay address and try again.") + return + } + let req = PairRequestPayload( + mobilePubkeyHex: identity.publicKeyHex, + displayName: displayName, + platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + ) + do { + logger.info("sending pair request via relay=\(self.relayURL.absoluteString, privacy: .public)") + try await client.send(.pairRequest(req), toHex: desktopHex) + } catch { + logger.error("pair request send failed: \(error.localizedDescription, privacy: .public)") + failPairing("Couldn't reach the relay. Check your network and try again.") + return + } + _ = desktopKey // silence unused + startPairingTimeout() + } + + func pair(from url: URL, displayName: String) async { + do { + let token = try PairingToken.parse(url.absoluteString) + await pair(with: token, displayName: displayName) + } catch { + logger.error("pairing deeplink parse failed: \(error.localizedDescription, privacy: .public)") + failPairing("Unrecognized pairing link. Generate a new QR code on your Mac.") + } + } + + func updateRelayForPairingIfNeeded(_ url: URL) async { + UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") + guard url != relayURL else { + logger.info("pairing relay already configured as \(url.absoluteString, privacy: .public)") + return + } + + logger.info("switching pairing relay to \(url.absoluteString, privacy: .public)") + let oldClient = client + eventTask?.cancel() + eventTask = nil + client = SyncClient(identity: identity, relayURL: url) + relayURL = url + connectionState = .disconnected + await oldClient.stop() + await startClient() + } + + func waitForRelayConnection(timeoutSeconds: Double = 8) async -> Bool { + logger.info("waiting for relay connection state=\(String(describing: self.connectionState), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + if connectionState == .connected { return true } + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + try? await Task.sleep(nanoseconds: 100_000_000) + if connectionState == .connected { return true } + } + return false + } + + func cancelPairing() { + pairingTimeoutTask?.cancel() + pairingTimeoutTask = nil + pairingStatus = .idle + } + + func dismissPairingError() { + if case .failed = pairingStatus { pairingStatus = .idle } + } + + func startPairingTimeout() { + pairingTimeoutTask?.cancel() + pairingTimeoutTask = Task { [weak self] in + let seconds = Self.pairingTimeoutSeconds + try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + guard self.pairingStatus == .inProgress else { return } + self.failPairing( + "Your Mac didn't respond. Make sure RxCode is open and connected, then try again." + ) + } + } + } +} diff --git a/RxCodeMobile/State/MobileAppState+Persistence.swift b/RxCodeMobile/State/MobileAppState+Persistence.swift new file mode 100644 index 0000000..204854b --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Persistence.swift @@ -0,0 +1,129 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeChatKit +import RxCodeSync +import SwiftUI +import UIKit +import os.log +extension MobileAppState { + func loadPairedDesktops() { + let defaults = UserDefaults.standard + let savedMobilePubkey = defaults.string(forKey: Self.mobilePubkeyKey) + if let savedMobilePubkey, + !savedMobilePubkey.isEmpty, + savedMobilePubkey != identity.publicKeyHex { + logger.warning("[Pairing] clearing stale saved desktop pairing savedMobileKey=\(String(savedMobilePubkey.prefix(12)), privacy: .public) currentMobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") + pairedDesktops = [] + setActiveDesktop(pubkeyHex: nil) + savePairedDesktops() + return + } + + if let data = defaults.data(forKey: Self.pairedDesktopsKey), + let decoded = try? JSONDecoder().decode([PairedDesktop].self, from: data) { + pairedDesktops = Self.deduplicate(decoded) + } else { + let legacyPubkey = defaults.string(forKey: Self.legacyDesktopPubkeyKey) ?? "" + let legacyName = defaults.string(forKey: Self.legacyDesktopNameKey) ?? "" + if !legacyPubkey.isEmpty { + pairedDesktops = [ + PairedDesktop( + pubkeyHex: legacyPubkey, + displayName: legacyName, + pairedAt: .now, + lastSeen: nil + ) + ] + } + } + + let preferred = defaults.string(forKey: Self.activeDesktopPubkeyKey) + ?? defaults.string(forKey: Self.legacyDesktopPubkeyKey) + setActiveDesktop(pubkeyHex: preferred) + if !pairedDesktops.isEmpty { + savePairedDesktops() + } + } + + func savePairedDesktops() { + let defaults = UserDefaults.standard + if pairedDesktops.isEmpty { + defaults.removeObject(forKey: Self.pairedDesktopsKey) + defaults.removeObject(forKey: Self.activeDesktopPubkeyKey) + defaults.removeObject(forKey: Self.legacyDesktopPubkeyKey) + defaults.removeObject(forKey: Self.legacyDesktopNameKey) + defaults.removeObject(forKey: Self.mobilePubkeyKey) + } else { + let encoder = JSONEncoder() + if let data = try? encoder.encode(pairedDesktops) { + defaults.set(data, forKey: Self.pairedDesktopsKey) + } + defaults.set(pairedDesktopPubkey, forKey: Self.activeDesktopPubkeyKey) + defaults.set(pairedDesktopPubkey, forKey: Self.legacyDesktopPubkeyKey) + defaults.set(pairedDesktopName, forKey: Self.legacyDesktopNameKey) + defaults.set(identity.publicKeyHex, forKey: Self.mobilePubkeyKey) + } + } + + func setActiveDesktop(pubkeyHex: String?) { + let resolved = pubkeyHex.flatMap { requested in + pairedDesktops.first { $0.pubkeyHex == requested } + } ?? pairedDesktops.first + + pairedDesktopPubkey = resolved?.pubkeyHex ?? "" + pairedDesktopName = resolved?.displayName ?? "" + isPaired = resolved != nil + } + + func upsertPairedDesktop(_ desktop: PairedDesktop) { + if let index = pairedDesktops.firstIndex(where: { $0.pubkeyHex == desktop.pubkeyHex }) { + let existing = pairedDesktops[index] + pairedDesktops[index] = PairedDesktop( + pubkeyHex: desktop.pubkeyHex, + displayName: desktop.displayName, + pairedAt: existing.pairedAt, + lastSeen: desktop.lastSeen ?? existing.lastSeen + ) + } else { + pairedDesktops.append(desktop) + } + } + + func acceptsActiveDesktopPayload(from pubkeyHex: String, type: String) -> Bool { + guard pubkeyHex == pairedDesktopPubkey else { + logger.info("[Pairing] ignoring \(type, privacy: .public) from inactive desktopKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") + return false + } + return true + } + + func removePairedDesktopAfterRemoteUnpair(_ desktop: PairedDesktop) async { + let wasActive = desktop.pubkeyHex == pairedDesktopPubkey + pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } + await client.removePeer(desktop.pubkeyHex) + if wasActive { + clearDesktopMirror() + setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) + } else { + setActiveDesktop(pubkeyHex: pairedDesktopPubkey) + } + savePairedDesktops() + if wasActive, isPaired { + await requestSnapshot() + await reportAPNsTokenIfPending() + } + } + + static func deduplicate(_ desktops: [PairedDesktop]) -> [PairedDesktop] { + var seen: Set = [] + var result: [PairedDesktop] = [] + for desktop in desktops { + guard !desktop.pubkeyHex.isEmpty, !seen.contains(desktop.pubkeyHex) else { continue } + seen.insert(desktop.pubkeyHex) + result.append(desktop) + } + return result + } +} diff --git a/RxCodeMobile/State/MobileAppState+RemoteConfig.swift b/RxCodeMobile/State/MobileAppState+RemoteConfig.swift new file mode 100644 index 0000000..ec634f5 --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+RemoteConfig.swift @@ -0,0 +1,296 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeChatKit +import RxCodeSync +import SwiftUI +import UIKit +import os.log +extension MobileAppState { + // MARK: - Remote desktop configuration + + /// Timeout after which a stuck remote config request is cleared and an + /// error surfaced. ACP installs download a binary, so they get longer. + static let remoteConfigTimeout: Duration = .seconds(20) + static let acpInstallTimeout: Duration = .seconds(90) + + /// Runs `perform` on the main actor after `timeout`. Callers use it to + /// expire a request that never received a reply (relay dropped, etc.). + func scheduleTimeout( + _ timeout: Duration, + perform: @escaping (MobileAppState) -> Void + ) { + Task { [weak self] in + try? await Task.sleep(for: timeout) + guard let self else { return } + perform(self) + } + } + + // Skills + + func requestSkillCatalog(forceRefresh: Bool = false) async { + guard isPaired else { + skillCatalogError = "Connect a Mac to browse skills." + return + } + let payload = SkillCatalogRequestPayload(forceRefresh: forceRefresh) + pendingSkillCatalogRequestID = payload.clientRequestID + skillCatalogLoading = true + skillCatalogError = nil + do { + try await client.send(.skillCatalogRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingSkillCatalogRequestID == payload.clientRequestID { + s.pendingSkillCatalogRequestID = nil + s.skillCatalogLoading = false + s.skillCatalogError = "Request timed out. Check your Mac and try again." + } + } + } catch { + skillCatalogLoading = false + skillCatalogError = "Failed to request skills: \(error.localizedDescription)" + if pendingSkillCatalogRequestID == payload.clientRequestID { + pendingSkillCatalogRequestID = nil + } + } + } + + func installSkill(_ pluginID: String) async { + await mutateSkill(pluginID, operation: .install) + } + + func uninstallSkill(_ pluginID: String) async { + await mutateSkill(pluginID, operation: .uninstall) + } + + func addSkillGitSource(url: String, ref: String?) async { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { + lastSkillError = "Enter a GitHub repository URL." + return + } + let trimmedRef = ref?.trimmingCharacters(in: .whitespacesAndNewlines) + let key = "add:\(trimmedURL)" + await mutateSkillSource( + key: key, + operation: .add, + gitURL: trimmedURL, + ref: trimmedRef?.isEmpty == false ? trimmedRef : nil + ) + } + + func removeSkillGitSource(_ sourceID: String) async { + await mutateSkillSource(key: sourceID, operation: .remove, sourceID: sourceID) + } + + func mutateSkill(_ pluginID: String, operation: SkillMutationRequestPayload.Operation) async { + guard isPaired else { + lastSkillError = "Connect a Mac first." + return + } + guard !inFlightSkillMutations.contains(pluginID) else { return } + let payload = SkillMutationRequestPayload(operation: operation, pluginID: pluginID) + inFlightSkillMutations.insert(pluginID) + lastSkillError = nil + do { + try await client.send(.skillMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.inFlightSkillMutations.remove(pluginID) != nil { + s.lastSkillError = "Request timed out. Check your Mac and try again." + } + } + } catch { + inFlightSkillMutations.remove(pluginID) + lastSkillError = "Failed to send request: \(error.localizedDescription)" + } + } + + func mutateSkillSource( + key: String, + operation: SkillSourceMutationRequestPayload.Operation, + sourceID: String? = nil, + gitURL: String? = nil, + ref: String? = nil + ) async { + guard isPaired else { + lastSkillError = "Connect a Mac first." + return + } + guard !inFlightSkillSourceMutations.contains(key) else { return } + let payload = SkillSourceMutationRequestPayload( + operation: operation, + sourceID: sourceID, + gitURL: gitURL, + ref: ref + ) + inFlightSkillSourceMutations.insert(key) + skillSourceMutationKeys[payload.clientRequestID] = key + lastSkillError = nil + do { + try await client.send(.skillSourceMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if let key = s.skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) { + s.inFlightSkillSourceMutations.remove(key) + s.lastSkillError = "Request timed out. Check your Mac and try again." + } + } + } catch { + skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) + inFlightSkillSourceMutations.remove(key) + lastSkillError = "Failed to send request: \(error.localizedDescription)" + } + } + + // ACP agent clients + + func requestACPRegistry(forceRefresh: Bool = false) async { + guard isPaired else { + acpRegistryError = "Connect a Mac to manage agents." + return + } + let payload = ACPRegistryRequestPayload(forceRefresh: forceRefresh) + pendingACPRegistryRequestID = payload.clientRequestID + acpRegistryLoading = true + acpRegistryError = nil + do { + try await client.send(.acpRegistryRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingACPRegistryRequestID == payload.clientRequestID { + s.pendingACPRegistryRequestID = nil + s.acpRegistryLoading = false + s.acpRegistryError = "Request timed out. Check your Mac and try again." + } + } + } catch { + acpRegistryLoading = false + acpRegistryError = "Failed to request agents: \(error.localizedDescription)" + if pendingACPRegistryRequestID == payload.clientRequestID { + pendingACPRegistryRequestID = nil + } + } + } + + func installACPAgent(_ registryAgentID: String) async { + await mutateACP(operation: .install, key: registryAgentID, registryAgentID: registryAgentID) + } + + func uninstallACPClient(_ clientID: String) async { + await mutateACP(operation: .uninstall, key: clientID, clientID: clientID) + } + + func setACPClientEnabled(_ clientID: String, enabled: Bool) async { + await mutateACP(operation: .setEnabled, key: clientID, clientID: clientID, enabled: enabled) + } + + func mutateACP( + operation: ACPMutationRequestPayload.Operation, + key: String, + registryAgentID: String? = nil, + clientID: String? = nil, + enabled: Bool? = nil + ) async { + guard isPaired else { + lastACPError = "Connect a Mac first." + return + } + guard !inFlightACPMutations.contains(key) else { return } + let payload = ACPMutationRequestPayload( + operation: operation, + registryAgentID: registryAgentID, + clientID: clientID, + enabled: enabled + ) + inFlightACPMutations.insert(key) + acpMutationKeys[payload.clientRequestID] = key + lastACPError = nil + let timeout = operation == .install ? Self.acpInstallTimeout : Self.remoteConfigTimeout + do { + try await client.send(.acpMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(timeout) { s in + if s.acpMutationKeys.removeValue(forKey: payload.clientRequestID) != nil { + s.inFlightACPMutations.remove(key) + s.lastACPError = "Request timed out. Check your Mac and try again." + } + } + } catch { + acpMutationKeys.removeValue(forKey: payload.clientRequestID) + inFlightACPMutations.remove(key) + lastACPError = "Failed to send request: \(error.localizedDescription)" + } + } + + // MCP servers + + func requestMCPConfig() async { + guard isPaired else { + mcpConfigError = "Connect a Mac to manage MCP servers." + return + } + let payload = MCPConfigRequestPayload() + pendingMCPConfigRequestID = payload.clientRequestID + mcpConfigLoading = true + mcpConfigError = nil + do { + try await client.send(.mcpConfigRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.pendingMCPConfigRequestID == payload.clientRequestID { + s.pendingMCPConfigRequestID = nil + s.mcpConfigLoading = false + s.mcpConfigError = "Request timed out. Check your Mac and try again." + } + } + } catch { + mcpConfigLoading = false + mcpConfigError = "Failed to request MCP servers: \(error.localizedDescription)" + if pendingMCPConfigRequestID == payload.clientRequestID { + pendingMCPConfigRequestID = nil + } + } + } + + func addMCPServer(_ server: MobileMCPServer) async { + await mutateMCP(operation: .add, serverName: server.name, server: server) + } + + func removeMCPServer(_ serverName: String) async { + await mutateMCP(operation: .remove, serverName: serverName) + } + + func setMCPServerEnabled(_ serverName: String, enabled: Bool) async { + await mutateMCP(operation: .setEnabled, serverName: serverName, enabled: enabled) + } + + func mutateMCP( + operation: MCPMutationRequestPayload.Operation, + serverName: String, + server: MobileMCPServer? = nil, + enabled: Bool? = nil + ) async { + guard isPaired else { + lastMCPError = "Connect a Mac first." + return + } + guard !inFlightMCPMutations.contains(serverName) else { return } + let payload = MCPMutationRequestPayload( + operation: operation, + serverName: serverName, + server: server, + enabled: enabled + ) + inFlightMCPMutations.insert(serverName) + lastMCPError = nil + do { + try await client.send(.mcpMutationRequest(payload), toHex: pairedDesktopPubkey) + scheduleTimeout(Self.remoteConfigTimeout) { s in + if s.inFlightMCPMutations.remove(serverName) != nil { + s.lastMCPError = "Request timed out. Check your Mac and try again." + } + } + } catch { + inFlightMCPMutations.remove(serverName) + lastMCPError = "Failed to send request: \(error.localizedDescription)" + } + } +} diff --git a/RxCodeMobile/State/MobileAppState+Sync.swift b/RxCodeMobile/State/MobileAppState+Sync.swift new file mode 100644 index 0000000..8d6743d --- /dev/null +++ b/RxCodeMobile/State/MobileAppState+Sync.swift @@ -0,0 +1,418 @@ +import Foundation +import Combine +import CryptoKit +import RxCodeCore +import RxCodeChatKit +import RxCodeSync +import SwiftUI +import UIKit +import os.log +extension MobileAppState { + // MARK: - Message paging + + /// Ask the desktop for the page of messages immediately older than the + /// oldest one currently loaded for `sessionID`. No-op when there's nothing + /// older, a request is already in flight, or the thread has no messages yet. + /// Returns `true` when a request was dispatched — callers can then expect + /// `isLoadingMoreMessages(sessionID:)` to flip back to `false` once it + /// settles. + @discardableResult + func loadMoreMessages(sessionID: String) async -> Bool { + guard isPaired, + sessionsWithMoreMessages.contains(sessionID), + !loadingMoreSessions.contains(sessionID), + let oldest = messagesBySession[sessionID]?.first + else { return false } + + let requestID = UUID() + loadingMoreSessions.insert(sessionID) + pendingLoadMoreRequests[requestID] = sessionID + + let payload = LoadMoreMessagesRequestPayload( + clientRequestID: requestID, + sessionID: sessionID, + beforeMessageID: oldest.id, + limit: Self.messagePageSize + ) + do { + try await client.send(.loadMoreMessages(payload), toHex: pairedDesktopPubkey) + } catch { + loadingMoreSessions.remove(sessionID) + pendingLoadMoreRequests.removeValue(forKey: requestID) + } + return true + } + + /// Fold an older page returned by the desktop into the local window. + func applyMoreMessages(_ page: MoreMessagesPayload) { + guard let sessionID = pendingLoadMoreRequests.removeValue(forKey: page.clientRequestID) + else { return } + loadingMoreSessions.remove(sessionID) + + if !page.messages.isEmpty { + var current = messagesBySession[sessionID] ?? [] + let known = Set(current.map(\.id)) + let fresh = page.messages.filter { !known.contains($0.id) } + if !fresh.isEmpty { + current.insert(contentsOf: fresh, at: 0) + messagesBySession[sessionID] = current + } + } + + if page.hasMore { + sessionsWithMoreMessages.insert(sessionID) + } else { + sessionsWithMoreMessages.remove(sessionID) + } + } + + /// Requests the change overview (thread file edits + uncommitted git + /// changes) for `sessionID` from the desktop. The reply lands in + /// `threadChanges` via the `threadChangesResult` payload. + func requestThreadChanges(sessionID: String) async { + guard isPaired else { return } + let requestID = UUID() + pendingThreadChangesID = requestID + isLoadingThreadChanges = true + let payload = ThreadChangesRequestPayload(clientRequestID: requestID, sessionID: sessionID) + do { + try await client.send(.threadChangesRequest(payload), toHex: pairedDesktopPubkey) + } catch { + if pendingThreadChangesID == requestID { + pendingThreadChangesID = nil + isLoadingThreadChanges = false + } + } + } + + func respondToPermission(allow: Bool, denyReason: String? = nil) async { + guard let pending = pendingPermission else { return } + let payload = PermissionResponsePayload( + requestID: pending.requestID, + allow: allow, + denyReason: denyReason + ) + try? await client.send(.permissionResponse(payload), toHex: pairedDesktopPubkey) + pendingPermission = nil + } + + // MARK: - AskUserQuestion + + /// Pending `AskUserQuestion` calls for one session, in the order the desktop + /// queued them. + func pendingQuestions(sessionID: String) -> [PendingQuestionPayload] { + pendingQuestions.filter { $0.sessionID == sessionID } + } + + /// Submit the user's answers for one `AskUserQuestion` call to the desktop. + /// The request is dropped from the local queue optimistically; the desktop + /// re-broadcasts the authoritative queue once it resolves the tool call. + func answerQuestion(toolUseID: String, answers: [Int: AskUserQuestion.Answer]) async { + guard isPaired else { return } + let entries: [QuestionAnswerEntry] = answers.map { index, answer in + switch answer { + case .single(let value): + return QuestionAnswerEntry(questionIndex: index, values: [value], multiSelect: false) + case .multi(let values): + return QuestionAnswerEntry(questionIndex: index, values: values, multiSelect: true) + } + } + let payload = QuestionAnswerPayload(toolUseID: toolUseID, answers: entries) + try? await client.send(.questionAnswer(payload), toHex: pairedDesktopPubkey) + pendingQuestions.removeAll { $0.toolUseID == toolUseID } + } + + /// Skip one `AskUserQuestion` call. An empty answer set tells the desktop to + /// resolve the tool call as denied. + func skipQuestion(toolUseID: String) async { + guard isPaired else { return } + let payload = QuestionAnswerPayload(toolUseID: toolUseID, answers: []) + try? await client.send(.questionAnswer(payload), toHex: pairedDesktopPubkey) + pendingQuestions.removeAll { $0.toolUseID == toolUseID } + } + + func updateDesktopSettings(_ update: MobileSettingsUpdatePayload) async { + guard isPaired else { return } + applySettingsUpdateLocally(update) + try? await client.send(.settingsUpdate(update), toHex: pairedDesktopPubkey) + } + + func refreshSnapshot() async { + await requestSnapshot() + } + + func runProfiles(for projectID: UUID) -> [RunProfile] { + runProfilesByProject[projectID] ?? [] + } + + func runTasks(for projectID: UUID) -> [MobileRunTaskSnapshot] { + runTasks.filter { $0.projectId == projectID } + } + + func runningTask(projectID: UUID, profileID: UUID) -> MobileRunTaskSnapshot? { + runTasks.first { + $0.projectId == projectID && $0.profileId == profileID && $0.isRunning + } + } + + func saveRunProfile(_ profile: RunProfile, projectID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] save dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public)") + return + } + var next = runProfilesByProject[projectID] ?? [] + if let idx = next.firstIndex(where: { $0.id == profile.id }) { + next[idx] = profile + } else { + next.append(profile) + } + runProfilesByProject[projectID] = next + + let payload = RunProfileMutationRequestPayload( + projectID: projectID, + operation: .upsert, + profile: profile, + profileID: profile.id + ) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileMutationRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent save request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send run profile update: \(error.localizedDescription)" + logger.error("[RunProfiles] save send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func deleteRunProfile(projectID: UUID, profileID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] delete dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + return + } + runProfilesByProject[projectID]?.removeAll { $0.id == profileID } + let payload = RunProfileMutationRequestPayload( + projectID: projectID, + operation: .delete, + profileID: profileID + ) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileMutationRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent delete request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send run profile delete: \(error.localizedDescription)" + logger.error("[RunProfiles] delete send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func runProfile(projectID: UUID, profileID: UUID) async { + guard isPaired else { + logger.error("[RunProfiles] run dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + return + } + let payload = RunProfileRunRequestPayload(projectID: projectID, profileID: profileID) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileRunRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent run request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send run request: \(error.localizedDescription)" + logger.error("[RunProfiles] run send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func stopRunTask(_ task: MobileRunTaskSnapshot) async { + guard isPaired else { + logger.error("[RunProfiles] stop dropped because mobile is not paired task=\(task.taskId.uuidString, privacy: .public)") + return + } + let payload = RunProfileStopRequestPayload( + taskID: task.taskId, + projectID: task.projectId, + profileID: task.profileId + ) + inFlightRunProfileRequests.insert(payload.clientRequestID) + do { + try await client.send(.runProfileStopRequest(payload), toHex: pairedDesktopPubkey) + logger.info("[RunProfiles] sent stop request id=\(payload.clientRequestID.uuidString, privacy: .public) task=\(task.taskId.uuidString, privacy: .public) project=\(task.projectId.uuidString, privacy: .public) profile=\(task.profileId.uuidString, privacy: .public)") + } catch { + inFlightRunProfileRequests.remove(payload.clientRequestID) + lastRunProfileError = "Failed to send stop request: \(error.localizedDescription)" + logger.error("[RunProfiles] stop send failed id=\(payload.clientRequestID.uuidString, privacy: .public) task=\(task.taskId.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + /// Update the search query and dispatch a debounced search request to the + /// paired desktop. Empty queries clear results without hitting the network. + /// Stale requests are discarded by `clientRequestID`. + func updateSearchQuery(_ query: String) { + searchQuery = query + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + searchDebounceTask?.cancel() + guard !trimmed.isEmpty else { + pendingSearchID = nil + isSearching = false + searchProjectIDs = [] + searchThreadHits = [] + return + } + guard isPaired else { + isSearching = false + searchProjectIDs = [] + searchThreadHits = [] + return + } + let id = UUID() + pendingSearchID = id + isSearching = true + searchDebounceTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 200_000_000) + guard !Task.isCancelled, let self else { return } + guard self.pendingSearchID == id else { return } + let payload = SearchRequestPayload(clientRequestID: id, query: trimmed, limit: 25) + try? await self.client.send(.searchRequest(payload), toHex: self.pairedDesktopPubkey) + } + } + + func reportAPNsToken(hex: String, environment: String) { + logger.info("[APNs] received token from app delegate tokenPrefix=\(String(hex.prefix(12)), privacy: .public) environment=\(environment, privacy: .public)") + apnsTokenHex = hex + apnsEnvironment = environment + Task { await reportAPNsTokenIfPending() } + } + + // MARK: - Live Activity & widget + + /// Forward a Live Activity push token (a push-to-start token, a per-activity + /// update token, or both) to every paired desktop so it can drive the job + /// Live Activity over APNs. Called by `MobileLiveActivityCoordinator`. + func sendLiveActivityToken(_ payload: LiveActivityTokenPayload) async { + guard !pairedDesktops.isEmpty else { return } + for desktop in pairedDesktops { + do { + try await client.send(.liveActivityToken(payload), toHex: desktop.pubkeyHex) + logger.info("[LiveActivity] token reported startToken=\(payload.pushToStartTokenHex != nil, privacy: .public) activityToken=\(payload.activityTokenHex != nil, privacy: .public) startedLocally=\(payload.activityStartedLocally == true, privacy: .public) dismissed=\(payload.activityDismissed == true, privacy: .public) session=\(payload.sessionID ?? "", privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public)") + } catch { + logger.error("[LiveActivity] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Recompute the home-screen widget snapshot from the current mirrored + /// state and persist it into the shared App Group container. Cheap to call + /// often — `RxCodeWidgetStore` reloads WidgetKit timelines on a real change. + func refreshWidgetData() { + let jobCount = sessions.filter { $0.isStreaming }.count + let snapshot = RxCodeWidgetData( + jobCount: jobCount, + ccUsagePercent: desktopUsage?.claudeCode?.fiveHourPercent, + codexUsagePercent: desktopUsage?.codex?.fiveHourPercent, + updatedAt: Date().timeIntervalSince1970 + ) + RxCodeWidgetStore.save(snapshot) + } + + /// Routes a tapped APNs notification to its thread. Called by `AppDelegate`'s + /// `didReceive` handler; `RootView` observes `pendingDeepLink` and navigates. + func openThreadFromNotification(sessionID: String, projectID: UUID?) { + logger.info("[APNs] notification tap -> open thread sessionID=\(sessionID, privacy: .public) projectID=\(projectID?.uuidString ?? "", privacy: .public)") + pendingDeepLink = MobileDeepLink(sessionID: sessionID, projectID: projectID) + } + + func switchPairedDesktop(_ desktop: PairedDesktop) async { + guard pairedDesktops.contains(where: { $0.pubkeyHex == desktop.pubkeyHex }) else { return } + guard desktop.pubkeyHex != pairedDesktopPubkey else { return } + clearDesktopMirror() + setActiveDesktop(pubkeyHex: desktop.pubkeyHex) + savePairedDesktops() + try? await client.addPeer(desktop.pubkeyHex) + await requestSnapshot() + await reportAPNsTokenIfPending() + } + + func removePairedDesktop(_ desktop: PairedDesktop) async { + let wasActive = desktop.pubkeyHex == pairedDesktopPubkey + try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: desktop.pubkeyHex) + pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } + await client.removePeer(desktop.pubkeyHex) + + if wasActive { + clearDesktopMirror() + setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) + } else { + setActiveDesktop(pubkeyHex: pairedDesktopPubkey) + } + savePairedDesktops() + + if wasActive, isPaired { + await requestSnapshot() + await reportAPNsTokenIfPending() + } + } + + func unpair() async { + guard let desktop = activePairedDesktop else { return } + await removePairedDesktop(desktop) + } + + func clearDesktopMirror() { + projects = [] + sessions = [] + branchBriefings = [] + threadSummaries = [] + desktopSettings = nil + desktopUsage = nil + desktopHostMetrics = nil + desktopWebProxy = nil + projectBranches = [:] + availableBranchesByProject = [:] + runProfilesByProject = [:] + runTasks = [] + inFlightRunProfileRequests = [] + lastRunProfileError = nil + inFlightBranchOps = [] + lastBranchOpError = nil + messagesBySession = [:] + thinkingSessions = [] + sessionsWithMoreMessages = [] + loadingMoreSessions = [] + pendingLoadMoreRequests = [:] + remoteFolderRoot = nil + remoteFolderIsLoading = false + remoteFolderError = nil + remoteProjectCreateInFlight = false + remoteProjectCreateError = nil + pendingFolderTreeRequestID = nil + pendingCreateProjectRequestID = nil + lastCreatedProjectID = nil + activeSessionID = nil + pendingPermission = nil + pendingQuestions = [] + skillCatalog = [] + skillCatalogLoading = false + skillCatalogError = nil + skillSources = [] + inFlightSkillMutations = [] + inFlightSkillSourceMutations = [] + lastSkillError = nil + pendingSkillCatalogRequestID = nil + skillSourceMutationKeys = [:] + acpRegistryAgents = [] + acpInstalledClients = [] + acpRegistryLoading = false + acpRegistryError = nil + inFlightACPMutations = [] + lastACPError = nil + pendingACPRegistryRequestID = nil + acpMutationKeys = [:] + mcpServers = [] + mcpConfigLoading = false + mcpConfigError = nil + inFlightMCPMutations = [] + lastMCPError = nil + pendingMCPConfigRequestID = nil + } +} diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index c3d766e..e2e2b31 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -87,8 +87,8 @@ final class MobileAppState: ObservableObject { @Published var inFlightSkillSourceMutations: Set = [] @Published var lastSkillError: String? /// The latest catalog request id, so a stale reply is discarded. - private var pendingSkillCatalogRequestID: UUID? - private var skillSourceMutationKeys: [UUID: String] = [:] + var pendingSkillCatalogRequestID: UUID? + var skillSourceMutationKeys: [UUID: String] = [:] // MARK: - Remote desktop config: ACP agent clients @@ -99,10 +99,10 @@ final class MobileAppState: ObservableObject { /// Registry-agent ids or installed-client ids with an in-flight mutation. @Published var inFlightACPMutations: Set = [] @Published var lastACPError: String? - private var pendingACPRegistryRequestID: UUID? + var pendingACPRegistryRequestID: UUID? /// Maps an ACP mutation request id to the identity key tracked in /// `inFlightACPMutations`, so the result clears the right row. - private var acpMutationKeys: [UUID: String] = [:] + var acpMutationKeys: [UUID: String] = [:] // MARK: - Remote desktop config: MCP servers @@ -112,7 +112,7 @@ final class MobileAppState: ObservableObject { /// Server names with an in-flight add/remove/toggle request. @Published var inFlightMCPMutations: Set = [] @Published var lastMCPError: String? - private var pendingMCPConfigRequestID: UUID? + var pendingMCPConfigRequestID: UUID? /// IDs of branch operations awaiting a `BranchOpResultPayload`. Used so the /// UI can render a spinner on the chip while the desktop runs git. @Published var inFlightBranchOps: Set = [] @@ -129,9 +129,9 @@ final class MobileAppState: ObservableObject { @Published var loadingMoreSessions: Set = [] /// Maps an outstanding load-more request ID to its session, so a late /// `more_messages` reply lands on the right thread. - private var pendingLoadMoreRequests: [UUID: String] = [:] + var pendingLoadMoreRequests: [UUID: String] = [:] /// Messages per history page — must match the desktop's expectation. - private static let messagePageSize = 30 + static let messagePageSize = 30 @Published var activeSessionID: String? /// Set when the user taps an APNs notification; `RootView` observes this and /// navigates to the thread's chat detail page, then clears it. @@ -146,15 +146,15 @@ final class MobileAppState: ObservableObject { @Published var searchProjectIDs: [UUID] = [] @Published var searchThreadHits: [SearchHit] = [] @Published var isSearching: Bool = false - private var pendingSearchID: UUID? - private var searchDebounceTask: Task? + var pendingSearchID: UUID? + var searchDebounceTask: Task? /// Backing data for the thread "View Changes" sheet — thread file edits and /// uncommitted git changes for one thread. Nil until first loaded; carries /// its own `sessionID` so a stale result for another thread is ignored. @Published var threadChanges: ThreadChangesResultPayload? @Published var isLoadingThreadChanges: Bool = false - private var pendingThreadChangesID: UUID? + var pendingThreadChangesID: UUID? @Published var remoteFolderRoot: RemoteFolderNode? @Published var remoteFolderIsLoading = false @@ -162,24 +162,24 @@ final class MobileAppState: ObservableObject { @Published var remoteProjectCreateInFlight = false @Published var remoteProjectCreateError: String? @Published var lastCreatedProjectID: UUID? - private var pendingFolderTreeRequestID: UUID? - private var pendingCreateProjectRequestID: UUID? - - private var identity: DeviceIdentity - private var client: SyncClient - private let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") - private var eventTask: Task? - private var pairingTimeoutTask: Task? - private var apnsTokenHex: String? - private var apnsEnvironment: String? - private var clientStarted = false + var pendingFolderTreeRequestID: UUID? + var pendingCreateProjectRequestID: UUID? + + var identity: DeviceIdentity + var client: SyncClient + let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") + var eventTask: Task? + var pairingTimeoutTask: Task? + var apnsTokenHex: String? + var apnsEnvironment: String? + var clientStarted = false static let pairingTimeoutSeconds: UInt64 = 25 - private static let pairedDesktopsKey = "mobileSync.pairedDesktops" - private static let activeDesktopPubkeyKey = "mobileSync.activeDesktopPubkey" - private static let legacyDesktopPubkeyKey = "mobileSync.desktopPubkey" - private static let legacyDesktopNameKey = "mobileSync.desktopName" - private static let mobilePubkeyKey = "mobileSync.mobilePubkey" + static let pairedDesktopsKey = "mobileSync.pairedDesktops" + static let activeDesktopPubkeyKey = "mobileSync.activeDesktopPubkey" + static let legacyDesktopPubkeyKey = "mobileSync.desktopPubkey" + static let legacyDesktopNameKey = "mobileSync.desktopName" + static let mobilePubkeyKey = "mobileSync.mobilePubkey" init() { let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") @@ -257,7 +257,7 @@ final class MobileAppState: ObservableObject { } } - private func startClient() async { + func startClient() async { clientStarted = true for desktop in pairedDesktops { try? await client.addPeer(desktop.pubkeyHex) @@ -277,1727 +277,6 @@ final class MobileAppState: ObservableObject { await reportAPNsTokenIfPending() } } - - // MARK: - Pairing - - func pair(with token: PairingToken, displayName: String) async { - guard !token.isExpired, - let desktopKey = token.desktopPublicKey else { - failPairing("Invalid or expired pairing code.") - return - } - pairingStatus = .inProgress - let desktopHex = token.desktopPubkeyHex - logger.info("pairing with relayURL=\(token.relayURL, privacy: .public)") - // Persist the relay URL we just learned from the QR. - if let url = URL(string: token.relayURL) { - await updateRelayForPairingIfNeeded(url) - } else { - logger.error("pairing token has invalid relayURL=\(token.relayURL, privacy: .public)") - } - if !clientStarted { - await startClient() - } - try? await client.addPeer(desktopHex) - guard await waitForRelayConnection() else { - logger.error("pairing relay connection timed out relay=\(self.relayURL.absoluteString, privacy: .public)") - failPairing("Couldn't connect to the relay from the QR code. Check the relay address and try again.") - return - } - let req = PairRequestPayload( - mobilePubkeyHex: identity.publicKeyHex, - displayName: displayName, - platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", - appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - ) - do { - logger.info("sending pair request via relay=\(self.relayURL.absoluteString, privacy: .public)") - try await client.send(.pairRequest(req), toHex: desktopHex) - } catch { - logger.error("pair request send failed: \(error.localizedDescription, privacy: .public)") - failPairing("Couldn't reach the relay. Check your network and try again.") - return - } - _ = desktopKey // silence unused - startPairingTimeout() - } - - func pair(from url: URL, displayName: String) async { - do { - let token = try PairingToken.parse(url.absoluteString) - await pair(with: token, displayName: displayName) - } catch { - logger.error("pairing deeplink parse failed: \(error.localizedDescription, privacy: .public)") - failPairing("Unrecognized pairing link. Generate a new QR code on your Mac.") - } - } - - private func updateRelayForPairingIfNeeded(_ url: URL) async { - UserDefaults.standard.set(url.absoluteString, forKey: "mobileSync.relayURL") - guard url != relayURL else { - logger.info("pairing relay already configured as \(url.absoluteString, privacy: .public)") - return - } - - logger.info("switching pairing relay to \(url.absoluteString, privacy: .public)") - let oldClient = client - eventTask?.cancel() - eventTask = nil - client = SyncClient(identity: identity, relayURL: url) - relayURL = url - connectionState = .disconnected - await oldClient.stop() - await startClient() - } - - private func waitForRelayConnection(timeoutSeconds: Double = 8) async -> Bool { - logger.info("waiting for relay connection state=\(String(describing: self.connectionState), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") - if connectionState == .connected { return true } - let deadline = Date().addingTimeInterval(timeoutSeconds) - while Date() < deadline { - try? await Task.sleep(nanoseconds: 100_000_000) - if connectionState == .connected { return true } - } - return false - } - - func cancelPairing() { - pairingTimeoutTask?.cancel() - pairingTimeoutTask = nil - pairingStatus = .idle - } - - func dismissPairingError() { - if case .failed = pairingStatus { pairingStatus = .idle } - } - - private func startPairingTimeout() { - pairingTimeoutTask?.cancel() - pairingTimeoutTask = Task { [weak self] in - let seconds = Self.pairingTimeoutSeconds - try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) - guard !Task.isCancelled else { return } - await MainActor.run { - guard let self else { return } - guard self.pairingStatus == .inProgress else { return } - self.failPairing( - "Your Mac didn't respond. Make sure RxCode is open and connected, then try again." - ) - } - } - } - - // MARK: - User intents - - func sendUserMessage(_ text: String, sessionID: String) async { - guard isPaired else { return } - let payload = UserMessagePayload(sessionID: sessionID, text: text) - try? await client.send(.userMessage(payload), toHex: pairedDesktopPubkey) - } - - func cancelStream(sessionID: String) async { - guard isPaired else { return } - let payload = CancelStreamPayload(sessionID: sessionID) - try? await client.send(.cancelStream(payload), toHex: pairedDesktopPubkey) - } - - func removeQueuedMessage(sessionID: String, queuedID: UUID) async { - guard isPaired else { return } - let payload = RemoveQueuedMessagePayload(sessionID: sessionID, queuedMessageID: queuedID) - try? await client.send(.removeQueuedMessage(payload), toHex: pairedDesktopPubkey) - } - - /// True iff the desktop reports the given session as actively streaming. - func isSessionStreaming(_ sessionID: String) -> Bool { - sessions.first(where: { $0.id == sessionID })?.isStreaming ?? false - } - - /// True iff the desktop reports the given session as currently producing - /// reasoning/thinking tokens. - func isSessionThinking(_ sessionID: String) -> Bool { - thinkingSessions.contains(sessionID) - } - - /// Whether the given session has messages older than the loaded window. - func hasMoreMessages(sessionID: String) -> Bool { - sessionsWithMoreMessages.contains(sessionID) - } - - /// Whether an older page is currently being fetched for the given session. - func isLoadingMoreMessages(sessionID: String) -> Bool { - loadingMoreSessions.contains(sessionID) - } - - /// Mirror of the desktop's per-session queue, surfaced via `SessionSummary`. - func queuedMessages(sessionID: String) -> [QueuedUserMessage] { - sessions.first(where: { $0.id == sessionID })?.queuedMessages ?? [] - } - - /// Ask the desktop to create a new thread. The per-thread agent config - /// (model, permission mode, plan mode) travels in the request so the thread - /// is created with exactly the chosen config — fixing the bug where a - /// non-default model was dropped in favor of the project default. ACP client - /// and effort have no mobile picker, so they ride along from the last synced - /// desktop settings. - func requestNewSession( - projectID: UUID, - initialText: String? = nil, - agentProvider: AgentProvider? = nil, - model: String? = nil, - permissionMode: PermissionMode? = nil, - planMode: Bool = false - ) async { - guard isPaired else { return } - let payload = NewSessionRequestPayload( - projectID: projectID, - initialText: initialText, - selectedAgentProvider: agentProvider, - selectedModel: model, - selectedACPClientId: desktopSettings?.selectedACPClientId, - selectedEffort: desktopSettings?.selectedEffort, - permissionMode: permissionMode, - planMode: planMode - ) - try? await client.send(.newSessionRequest(payload), toHex: pairedDesktopPubkey) - } - - func requestRemoteFolder(path: String? = nil) async { - guard isPaired else { return } - let request = FolderTreeRequestPayload(path: path, depth: 1) - pendingFolderTreeRequestID = request.clientRequestID - remoteFolderIsLoading = true - remoteFolderError = nil - do { - try await client.send(.folderTreeRequest(request), toHex: pairedDesktopPubkey) - } catch { - pendingFolderTreeRequestID = nil - remoteFolderIsLoading = false - remoteFolderError = error.localizedDescription - } - } - - func createProjectFromRemoteFolder(path: String) async { - guard isPaired else { return } - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let request = CreateProjectRequestPayload(path: trimmed) - pendingCreateProjectRequestID = request.clientRequestID - remoteProjectCreateInFlight = true - remoteProjectCreateError = nil - do { - try await client.send(.createProjectRequest(request), toHex: pairedDesktopPubkey) - } catch { - pendingCreateProjectRequestID = nil - remoteProjectCreateInFlight = false - remoteProjectCreateError = error.localizedDescription - } - } - - // MARK: - Plan mode - - /// Plan cards for a session, derived live from the synced messages using the - /// shared `PlanLogic` — the same `ExitPlanMode` detection the desktop chat - /// uses. Superseded plans (re-emitted within the same turn) are dropped so - /// only actionable cards surface. - func pendingPlans(sessionID: String) -> [PendingPlan] { - let messages = messagesBySession[sessionID] ?? [] - var plans: [PendingPlan] = [] - for message in messages { - for block in message.blocks { - guard let toolCall = block.toolCall, - PlanLogic.isExitPlanMode(toolCall) else { continue } - if PlanLogic.isSupersededExitPlanMode( - toolCall: toolCall, in: message, allMessages: messages - ) { continue } - let markdown = PlanLogic.renderMarkdown(for: toolCall, in: message) ?? "" - let decided = PlanLogic.isPlanDecided(toolCall) - plans.append(PendingPlan( - toolCallId: toolCall.id, - markdown: markdown, - isStreaming: !decided && markdown.isEmpty && message.isStreaming, - isDecided: decided, - decisionSummary: decided ? toolCall.result : nil - )) - } - } - return plans - } - - /// Send the user's plan decision to the desktop, which resolves the CLI - /// `ExitPlanMode` hook via `respondToPlanDecision`. No optimistic mutation — - /// the desktop broadcasts the updated `toolCall.result` back through the - /// normal session-update sync, so the banner and chat reconcile from that. - func respondToPlanDecision( - toolUseID: String, - sessionID: String, - action: PlanDecisionAction - ) async { - guard isPaired else { return } - let payload = PlanDecisionPayload( - toolUseID: toolUseID, - sessionID: sessionID, - decision: action - ) - try? await client.send(.planDecision(payload), toHex: pairedDesktopPubkey) - } - - // MARK: - Thread lifecycle actions - - /// Rename a thread. The local title is updated optimistically; the desktop - /// confirms via the next snapshot / session update. - func renameThread(sessionID: String, title: String) async { - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - replaceSession(sessionID: sessionID) { current in - SessionSummary( - id: current.id, - projectId: current.projectId, - title: trimmed, - updatedAt: current.updatedAt, - isPinned: current.isPinned, - isArchived: current.isArchived, - isStreaming: current.isStreaming, - attention: current.attention, - progress: current.progress, - queuedMessages: current.queuedMessages - ) - } - await sendThreadAction(sessionID: sessionID, action: .rename, newTitle: trimmed) - } - - /// Archive a thread. Optimistically flips `isArchived` so the row drops out - /// of the active list right away. - func archiveThread(sessionID: String) async { - replaceSession(sessionID: sessionID) { current in - SessionSummary( - id: current.id, - projectId: current.projectId, - title: current.title, - updatedAt: current.updatedAt, - isPinned: current.isPinned, - isArchived: true, - isStreaming: current.isStreaming, - attention: current.attention, - progress: current.progress, - queuedMessages: current.queuedMessages - ) - } - await sendThreadAction(sessionID: sessionID, action: .archive) - } - - /// Delete a thread. Optimistically drops it from local state. - func deleteThread(sessionID: String) async { - sessions.removeAll { $0.id == sessionID } - messagesBySession.removeValue(forKey: sessionID) - sessionsWithMoreMessages.remove(sessionID) - loadingMoreSessions.remove(sessionID) - if activeSessionID == sessionID { activeSessionID = nil } - await sendThreadAction(sessionID: sessionID, action: .delete) - } - - private func replaceSession(sessionID: String, _ transform: (SessionSummary) -> SessionSummary) { - guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return } - sessions[index] = transform(sessions[index]) - } - - private func sendThreadAction( - sessionID: String, - action: ThreadActionRequestPayload.Action, - newTitle: String? = nil - ) async { - guard isPaired else { - logger.error("[ThreadAction] not paired — dropping action=\(action.rawValue, privacy: .public)") - return - } - let payload = ThreadActionRequestPayload( - sessionID: sessionID, - action: action, - newTitle: newTitle - ) - do { - try await client.send(.threadActionRequest(payload), toHex: pairedDesktopPubkey) - logger.info("[ThreadAction] sent action=\(action.rawValue, privacy: .public) thread=\(sessionID, privacy: .public)") - } catch { - logger.error("[ThreadAction] send failed action=\(action.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - /// Tell the desktop to switch the project to an existing local branch. - /// Returns immediately; the eventual snapshot broadcast carries the new - /// `currentBranch` and any error surfaces via `lastBranchOpError`. - func switchProjectBranch(projectID: UUID, branch: String) async { - guard isPaired else { return } - let request = BranchOpRequestPayload( - projectID: projectID, - operation: .switchExisting, - branch: branch - ) - inFlightBranchOps.insert(request.clientRequestID) - try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) - } - - /// Tell the desktop to create a new branch + worktree off the current - /// branch. The desktop parks the worktree against the project so the next - /// new-thread request for this project spawns into it. - func createProjectBranch(projectID: UUID, branch: String) async { - guard isPaired else { return } - let request = BranchOpRequestPayload( - projectID: projectID, - operation: .createNew, - branch: branch - ) - inFlightBranchOps.insert(request.clientRequestID) - try? await client.send(.branchOpRequest(request), toHex: pairedDesktopPubkey) - } - - func clearBranchOpError() { - lastBranchOpError = nil - } - - func subscribe(to sessionID: String?) async { - activeSessionID = sessionID - guard isPaired else { return } - let payload = SubscribeSessionPayload(sessionID: sessionID) - try? await client.send(.subscribeSession(payload), toHex: pairedDesktopPubkey) - } - - // MARK: - Message paging - - /// Ask the desktop for the page of messages immediately older than the - /// oldest one currently loaded for `sessionID`. No-op when there's nothing - /// older, a request is already in flight, or the thread has no messages yet. - /// Returns `true` when a request was dispatched — callers can then expect - /// `isLoadingMoreMessages(sessionID:)` to flip back to `false` once it - /// settles. - @discardableResult - func loadMoreMessages(sessionID: String) async -> Bool { - guard isPaired, - sessionsWithMoreMessages.contains(sessionID), - !loadingMoreSessions.contains(sessionID), - let oldest = messagesBySession[sessionID]?.first - else { return false } - - let requestID = UUID() - loadingMoreSessions.insert(sessionID) - pendingLoadMoreRequests[requestID] = sessionID - - let payload = LoadMoreMessagesRequestPayload( - clientRequestID: requestID, - sessionID: sessionID, - beforeMessageID: oldest.id, - limit: Self.messagePageSize - ) - do { - try await client.send(.loadMoreMessages(payload), toHex: pairedDesktopPubkey) - } catch { - loadingMoreSessions.remove(sessionID) - pendingLoadMoreRequests.removeValue(forKey: requestID) - } - return true - } - - /// Fold an older page returned by the desktop into the local window. - private func applyMoreMessages(_ page: MoreMessagesPayload) { - guard let sessionID = pendingLoadMoreRequests.removeValue(forKey: page.clientRequestID) - else { return } - loadingMoreSessions.remove(sessionID) - - if !page.messages.isEmpty { - var current = messagesBySession[sessionID] ?? [] - let known = Set(current.map(\.id)) - let fresh = page.messages.filter { !known.contains($0.id) } - if !fresh.isEmpty { - current.insert(contentsOf: fresh, at: 0) - messagesBySession[sessionID] = current - } - } - - if page.hasMore { - sessionsWithMoreMessages.insert(sessionID) - } else { - sessionsWithMoreMessages.remove(sessionID) - } - } - - /// Requests the change overview (thread file edits + uncommitted git - /// changes) for `sessionID` from the desktop. The reply lands in - /// `threadChanges` via the `threadChangesResult` payload. - func requestThreadChanges(sessionID: String) async { - guard isPaired else { return } - let requestID = UUID() - pendingThreadChangesID = requestID - isLoadingThreadChanges = true - let payload = ThreadChangesRequestPayload(clientRequestID: requestID, sessionID: sessionID) - do { - try await client.send(.threadChangesRequest(payload), toHex: pairedDesktopPubkey) - } catch { - if pendingThreadChangesID == requestID { - pendingThreadChangesID = nil - isLoadingThreadChanges = false - } - } - } - - func respondToPermission(allow: Bool, denyReason: String? = nil) async { - guard let pending = pendingPermission else { return } - let payload = PermissionResponsePayload( - requestID: pending.requestID, - allow: allow, - denyReason: denyReason - ) - try? await client.send(.permissionResponse(payload), toHex: pairedDesktopPubkey) - pendingPermission = nil - } - - // MARK: - AskUserQuestion - - /// Pending `AskUserQuestion` calls for one session, in the order the desktop - /// queued them. - func pendingQuestions(sessionID: String) -> [PendingQuestionPayload] { - pendingQuestions.filter { $0.sessionID == sessionID } - } - - /// Submit the user's answers for one `AskUserQuestion` call to the desktop. - /// The request is dropped from the local queue optimistically; the desktop - /// re-broadcasts the authoritative queue once it resolves the tool call. - func answerQuestion(toolUseID: String, answers: [Int: AskUserQuestion.Answer]) async { - guard isPaired else { return } - let entries: [QuestionAnswerEntry] = answers.map { index, answer in - switch answer { - case .single(let value): - return QuestionAnswerEntry(questionIndex: index, values: [value], multiSelect: false) - case .multi(let values): - return QuestionAnswerEntry(questionIndex: index, values: values, multiSelect: true) - } - } - let payload = QuestionAnswerPayload(toolUseID: toolUseID, answers: entries) - try? await client.send(.questionAnswer(payload), toHex: pairedDesktopPubkey) - pendingQuestions.removeAll { $0.toolUseID == toolUseID } - } - - /// Skip one `AskUserQuestion` call. An empty answer set tells the desktop to - /// resolve the tool call as denied. - func skipQuestion(toolUseID: String) async { - guard isPaired else { return } - let payload = QuestionAnswerPayload(toolUseID: toolUseID, answers: []) - try? await client.send(.questionAnswer(payload), toHex: pairedDesktopPubkey) - pendingQuestions.removeAll { $0.toolUseID == toolUseID } - } - - func updateDesktopSettings(_ update: MobileSettingsUpdatePayload) async { - guard isPaired else { return } - applySettingsUpdateLocally(update) - try? await client.send(.settingsUpdate(update), toHex: pairedDesktopPubkey) - } - - func refreshSnapshot() async { - await requestSnapshot() - } - - func runProfiles(for projectID: UUID) -> [RunProfile] { - runProfilesByProject[projectID] ?? [] - } - - func runTasks(for projectID: UUID) -> [MobileRunTaskSnapshot] { - runTasks.filter { $0.projectId == projectID } - } - - func runningTask(projectID: UUID, profileID: UUID) -> MobileRunTaskSnapshot? { - runTasks.first { - $0.projectId == projectID && $0.profileId == profileID && $0.isRunning - } - } - - func saveRunProfile(_ profile: RunProfile, projectID: UUID) async { - guard isPaired else { - logger.error("[RunProfiles] save dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public)") - return - } - var next = runProfilesByProject[projectID] ?? [] - if let idx = next.firstIndex(where: { $0.id == profile.id }) { - next[idx] = profile - } else { - next.append(profile) - } - runProfilesByProject[projectID] = next - - let payload = RunProfileMutationRequestPayload( - projectID: projectID, - operation: .upsert, - profile: profile, - profileID: profile.id - ) - inFlightRunProfileRequests.insert(payload.clientRequestID) - do { - try await client.send(.runProfileMutationRequest(payload), toHex: pairedDesktopPubkey) - logger.info("[RunProfiles] sent save request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") - } catch { - inFlightRunProfileRequests.remove(payload.clientRequestID) - lastRunProfileError = "Failed to send run profile update: \(error.localizedDescription)" - logger.error("[RunProfiles] save send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profile.id.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - func deleteRunProfile(projectID: UUID, profileID: UUID) async { - guard isPaired else { - logger.error("[RunProfiles] delete dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") - return - } - runProfilesByProject[projectID]?.removeAll { $0.id == profileID } - let payload = RunProfileMutationRequestPayload( - projectID: projectID, - operation: .delete, - profileID: profileID - ) - inFlightRunProfileRequests.insert(payload.clientRequestID) - do { - try await client.send(.runProfileMutationRequest(payload), toHex: pairedDesktopPubkey) - logger.info("[RunProfiles] sent delete request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") - } catch { - inFlightRunProfileRequests.remove(payload.clientRequestID) - lastRunProfileError = "Failed to send run profile delete: \(error.localizedDescription)" - logger.error("[RunProfiles] delete send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - func runProfile(projectID: UUID, profileID: UUID) async { - guard isPaired else { - logger.error("[RunProfiles] run dropped because mobile is not paired project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") - return - } - let payload = RunProfileRunRequestPayload(projectID: projectID, profileID: profileID) - inFlightRunProfileRequests.insert(payload.clientRequestID) - do { - try await client.send(.runProfileRunRequest(payload), toHex: pairedDesktopPubkey) - logger.info("[RunProfiles] sent run request id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public)") - } catch { - inFlightRunProfileRequests.remove(payload.clientRequestID) - lastRunProfileError = "Failed to send run request: \(error.localizedDescription)" - logger.error("[RunProfiles] run send failed id=\(payload.clientRequestID.uuidString, privacy: .public) project=\(projectID.uuidString, privacy: .public) profile=\(profileID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - func stopRunTask(_ task: MobileRunTaskSnapshot) async { - guard isPaired else { - logger.error("[RunProfiles] stop dropped because mobile is not paired task=\(task.taskId.uuidString, privacy: .public)") - return - } - let payload = RunProfileStopRequestPayload( - taskID: task.taskId, - projectID: task.projectId, - profileID: task.profileId - ) - inFlightRunProfileRequests.insert(payload.clientRequestID) - do { - try await client.send(.runProfileStopRequest(payload), toHex: pairedDesktopPubkey) - logger.info("[RunProfiles] sent stop request id=\(payload.clientRequestID.uuidString, privacy: .public) task=\(task.taskId.uuidString, privacy: .public) project=\(task.projectId.uuidString, privacy: .public) profile=\(task.profileId.uuidString, privacy: .public)") - } catch { - inFlightRunProfileRequests.remove(payload.clientRequestID) - lastRunProfileError = "Failed to send stop request: \(error.localizedDescription)" - logger.error("[RunProfiles] stop send failed id=\(payload.clientRequestID.uuidString, privacy: .public) task=\(task.taskId.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - /// Update the search query and dispatch a debounced search request to the - /// paired desktop. Empty queries clear results without hitting the network. - /// Stale requests are discarded by `clientRequestID`. - func updateSearchQuery(_ query: String) { - searchQuery = query - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - searchDebounceTask?.cancel() - guard !trimmed.isEmpty else { - pendingSearchID = nil - isSearching = false - searchProjectIDs = [] - searchThreadHits = [] - return - } - guard isPaired else { - isSearching = false - searchProjectIDs = [] - searchThreadHits = [] - return - } - let id = UUID() - pendingSearchID = id - isSearching = true - searchDebounceTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 200_000_000) - guard !Task.isCancelled, let self else { return } - guard self.pendingSearchID == id else { return } - let payload = SearchRequestPayload(clientRequestID: id, query: trimmed, limit: 25) - try? await self.client.send(.searchRequest(payload), toHex: self.pairedDesktopPubkey) - } - } - - func reportAPNsToken(hex: String, environment: String) { - logger.info("[APNs] received token from app delegate tokenPrefix=\(String(hex.prefix(12)), privacy: .public) environment=\(environment, privacy: .public)") - apnsTokenHex = hex - apnsEnvironment = environment - Task { await reportAPNsTokenIfPending() } - } - - // MARK: - Live Activity & widget - - /// Forward a Live Activity push token (a push-to-start token, a per-activity - /// update token, or both) to every paired desktop so it can drive the job - /// Live Activity over APNs. Called by `MobileLiveActivityCoordinator`. - func sendLiveActivityToken(_ payload: LiveActivityTokenPayload) async { - guard !pairedDesktops.isEmpty else { return } - for desktop in pairedDesktops { - do { - try await client.send(.liveActivityToken(payload), toHex: desktop.pubkeyHex) - logger.info("[LiveActivity] token reported startToken=\(payload.pushToStartTokenHex != nil, privacy: .public) activityToken=\(payload.activityTokenHex != nil, privacy: .public) startedLocally=\(payload.activityStartedLocally == true, privacy: .public) dismissed=\(payload.activityDismissed == true, privacy: .public) session=\(payload.sessionID ?? "", privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public)") - } catch { - logger.error("[LiveActivity] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - } - - /// Recompute the home-screen widget snapshot from the current mirrored - /// state and persist it into the shared App Group container. Cheap to call - /// often — `RxCodeWidgetStore` reloads WidgetKit timelines on a real change. - func refreshWidgetData() { - let jobCount = sessions.filter { $0.isStreaming }.count - let snapshot = RxCodeWidgetData( - jobCount: jobCount, - ccUsagePercent: desktopUsage?.claudeCode?.fiveHourPercent, - codexUsagePercent: desktopUsage?.codex?.fiveHourPercent, - updatedAt: Date().timeIntervalSince1970 - ) - RxCodeWidgetStore.save(snapshot) - } - - /// Routes a tapped APNs notification to its thread. Called by `AppDelegate`'s - /// `didReceive` handler; `RootView` observes `pendingDeepLink` and navigates. - func openThreadFromNotification(sessionID: String, projectID: UUID?) { - logger.info("[APNs] notification tap -> open thread sessionID=\(sessionID, privacy: .public) projectID=\(projectID?.uuidString ?? "", privacy: .public)") - pendingDeepLink = MobileDeepLink(sessionID: sessionID, projectID: projectID) - } - - func switchPairedDesktop(_ desktop: PairedDesktop) async { - guard pairedDesktops.contains(where: { $0.pubkeyHex == desktop.pubkeyHex }) else { return } - guard desktop.pubkeyHex != pairedDesktopPubkey else { return } - clearDesktopMirror() - setActiveDesktop(pubkeyHex: desktop.pubkeyHex) - savePairedDesktops() - try? await client.addPeer(desktop.pubkeyHex) - await requestSnapshot() - await reportAPNsTokenIfPending() - } - - func removePairedDesktop(_ desktop: PairedDesktop) async { - let wasActive = desktop.pubkeyHex == pairedDesktopPubkey - try? await client.send(.unpair(UnpairPayload(reason: "mobile")), toHex: desktop.pubkeyHex) - pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } - await client.removePeer(desktop.pubkeyHex) - - if wasActive { - clearDesktopMirror() - setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) - } else { - setActiveDesktop(pubkeyHex: pairedDesktopPubkey) - } - savePairedDesktops() - - if wasActive, isPaired { - await requestSnapshot() - await reportAPNsTokenIfPending() - } - } - - func unpair() async { - guard let desktop = activePairedDesktop else { return } - await removePairedDesktop(desktop) - } - - private func clearDesktopMirror() { - projects = [] - sessions = [] - branchBriefings = [] - threadSummaries = [] - desktopSettings = nil - desktopUsage = nil - desktopHostMetrics = nil - desktopWebProxy = nil - projectBranches = [:] - availableBranchesByProject = [:] - runProfilesByProject = [:] - runTasks = [] - inFlightRunProfileRequests = [] - lastRunProfileError = nil - inFlightBranchOps = [] - lastBranchOpError = nil - messagesBySession = [:] - thinkingSessions = [] - sessionsWithMoreMessages = [] - loadingMoreSessions = [] - pendingLoadMoreRequests = [:] - remoteFolderRoot = nil - remoteFolderIsLoading = false - remoteFolderError = nil - remoteProjectCreateInFlight = false - remoteProjectCreateError = nil - pendingFolderTreeRequestID = nil - pendingCreateProjectRequestID = nil - lastCreatedProjectID = nil - activeSessionID = nil - pendingPermission = nil - pendingQuestions = [] - skillCatalog = [] - skillCatalogLoading = false - skillCatalogError = nil - skillSources = [] - inFlightSkillMutations = [] - inFlightSkillSourceMutations = [] - lastSkillError = nil - pendingSkillCatalogRequestID = nil - skillSourceMutationKeys = [:] - acpRegistryAgents = [] - acpInstalledClients = [] - acpRegistryLoading = false - acpRegistryError = nil - inFlightACPMutations = [] - lastACPError = nil - pendingACPRegistryRequestID = nil - acpMutationKeys = [:] - mcpServers = [] - mcpConfigLoading = false - mcpConfigError = nil - inFlightMCPMutations = [] - lastMCPError = nil - pendingMCPConfigRequestID = nil - } - - // MARK: - Remote desktop configuration - - /// Timeout after which a stuck remote config request is cleared and an - /// error surfaced. ACP installs download a binary, so they get longer. - private static let remoteConfigTimeout: Duration = .seconds(20) - private static let acpInstallTimeout: Duration = .seconds(90) - - /// Runs `perform` on the main actor after `timeout`. Callers use it to - /// expire a request that never received a reply (relay dropped, etc.). - private func scheduleTimeout( - _ timeout: Duration, - perform: @escaping (MobileAppState) -> Void - ) { - Task { [weak self] in - try? await Task.sleep(for: timeout) - guard let self else { return } - perform(self) - } - } - - // Skills - - func requestSkillCatalog(forceRefresh: Bool = false) async { - guard isPaired else { - skillCatalogError = "Connect a Mac to browse skills." - return - } - let payload = SkillCatalogRequestPayload(forceRefresh: forceRefresh) - pendingSkillCatalogRequestID = payload.clientRequestID - skillCatalogLoading = true - skillCatalogError = nil - do { - try await client.send(.skillCatalogRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(Self.remoteConfigTimeout) { s in - if s.pendingSkillCatalogRequestID == payload.clientRequestID { - s.pendingSkillCatalogRequestID = nil - s.skillCatalogLoading = false - s.skillCatalogError = "Request timed out. Check your Mac and try again." - } - } - } catch { - skillCatalogLoading = false - skillCatalogError = "Failed to request skills: \(error.localizedDescription)" - if pendingSkillCatalogRequestID == payload.clientRequestID { - pendingSkillCatalogRequestID = nil - } - } - } - - func installSkill(_ pluginID: String) async { - await mutateSkill(pluginID, operation: .install) - } - - func uninstallSkill(_ pluginID: String) async { - await mutateSkill(pluginID, operation: .uninstall) - } - - func addSkillGitSource(url: String, ref: String?) async { - let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedURL.isEmpty else { - lastSkillError = "Enter a GitHub repository URL." - return - } - let trimmedRef = ref?.trimmingCharacters(in: .whitespacesAndNewlines) - let key = "add:\(trimmedURL)" - await mutateSkillSource( - key: key, - operation: .add, - gitURL: trimmedURL, - ref: trimmedRef?.isEmpty == false ? trimmedRef : nil - ) - } - - func removeSkillGitSource(_ sourceID: String) async { - await mutateSkillSource(key: sourceID, operation: .remove, sourceID: sourceID) - } - - private func mutateSkill(_ pluginID: String, operation: SkillMutationRequestPayload.Operation) async { - guard isPaired else { - lastSkillError = "Connect a Mac first." - return - } - guard !inFlightSkillMutations.contains(pluginID) else { return } - let payload = SkillMutationRequestPayload(operation: operation, pluginID: pluginID) - inFlightSkillMutations.insert(pluginID) - lastSkillError = nil - do { - try await client.send(.skillMutationRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(Self.remoteConfigTimeout) { s in - if s.inFlightSkillMutations.remove(pluginID) != nil { - s.lastSkillError = "Request timed out. Check your Mac and try again." - } - } - } catch { - inFlightSkillMutations.remove(pluginID) - lastSkillError = "Failed to send request: \(error.localizedDescription)" - } - } - - private func mutateSkillSource( - key: String, - operation: SkillSourceMutationRequestPayload.Operation, - sourceID: String? = nil, - gitURL: String? = nil, - ref: String? = nil - ) async { - guard isPaired else { - lastSkillError = "Connect a Mac first." - return - } - guard !inFlightSkillSourceMutations.contains(key) else { return } - let payload = SkillSourceMutationRequestPayload( - operation: operation, - sourceID: sourceID, - gitURL: gitURL, - ref: ref - ) - inFlightSkillSourceMutations.insert(key) - skillSourceMutationKeys[payload.clientRequestID] = key - lastSkillError = nil - do { - try await client.send(.skillSourceMutationRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(Self.remoteConfigTimeout) { s in - if let key = s.skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) { - s.inFlightSkillSourceMutations.remove(key) - s.lastSkillError = "Request timed out. Check your Mac and try again." - } - } - } catch { - skillSourceMutationKeys.removeValue(forKey: payload.clientRequestID) - inFlightSkillSourceMutations.remove(key) - lastSkillError = "Failed to send request: \(error.localizedDescription)" - } - } - - // ACP agent clients - - func requestACPRegistry(forceRefresh: Bool = false) async { - guard isPaired else { - acpRegistryError = "Connect a Mac to manage agents." - return - } - let payload = ACPRegistryRequestPayload(forceRefresh: forceRefresh) - pendingACPRegistryRequestID = payload.clientRequestID - acpRegistryLoading = true - acpRegistryError = nil - do { - try await client.send(.acpRegistryRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(Self.remoteConfigTimeout) { s in - if s.pendingACPRegistryRequestID == payload.clientRequestID { - s.pendingACPRegistryRequestID = nil - s.acpRegistryLoading = false - s.acpRegistryError = "Request timed out. Check your Mac and try again." - } - } - } catch { - acpRegistryLoading = false - acpRegistryError = "Failed to request agents: \(error.localizedDescription)" - if pendingACPRegistryRequestID == payload.clientRequestID { - pendingACPRegistryRequestID = nil - } - } - } - - func installACPAgent(_ registryAgentID: String) async { - await mutateACP(operation: .install, key: registryAgentID, registryAgentID: registryAgentID) - } - - func uninstallACPClient(_ clientID: String) async { - await mutateACP(operation: .uninstall, key: clientID, clientID: clientID) - } - - func setACPClientEnabled(_ clientID: String, enabled: Bool) async { - await mutateACP(operation: .setEnabled, key: clientID, clientID: clientID, enabled: enabled) - } - - private func mutateACP( - operation: ACPMutationRequestPayload.Operation, - key: String, - registryAgentID: String? = nil, - clientID: String? = nil, - enabled: Bool? = nil - ) async { - guard isPaired else { - lastACPError = "Connect a Mac first." - return - } - guard !inFlightACPMutations.contains(key) else { return } - let payload = ACPMutationRequestPayload( - operation: operation, - registryAgentID: registryAgentID, - clientID: clientID, - enabled: enabled - ) - inFlightACPMutations.insert(key) - acpMutationKeys[payload.clientRequestID] = key - lastACPError = nil - let timeout = operation == .install ? Self.acpInstallTimeout : Self.remoteConfigTimeout - do { - try await client.send(.acpMutationRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(timeout) { s in - if s.acpMutationKeys.removeValue(forKey: payload.clientRequestID) != nil { - s.inFlightACPMutations.remove(key) - s.lastACPError = "Request timed out. Check your Mac and try again." - } - } - } catch { - acpMutationKeys.removeValue(forKey: payload.clientRequestID) - inFlightACPMutations.remove(key) - lastACPError = "Failed to send request: \(error.localizedDescription)" - } - } - - // MCP servers - - func requestMCPConfig() async { - guard isPaired else { - mcpConfigError = "Connect a Mac to manage MCP servers." - return - } - let payload = MCPConfigRequestPayload() - pendingMCPConfigRequestID = payload.clientRequestID - mcpConfigLoading = true - mcpConfigError = nil - do { - try await client.send(.mcpConfigRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(Self.remoteConfigTimeout) { s in - if s.pendingMCPConfigRequestID == payload.clientRequestID { - s.pendingMCPConfigRequestID = nil - s.mcpConfigLoading = false - s.mcpConfigError = "Request timed out. Check your Mac and try again." - } - } - } catch { - mcpConfigLoading = false - mcpConfigError = "Failed to request MCP servers: \(error.localizedDescription)" - if pendingMCPConfigRequestID == payload.clientRequestID { - pendingMCPConfigRequestID = nil - } - } - } - - func addMCPServer(_ server: MobileMCPServer) async { - await mutateMCP(operation: .add, serverName: server.name, server: server) - } - - func removeMCPServer(_ serverName: String) async { - await mutateMCP(operation: .remove, serverName: serverName) - } - - func setMCPServerEnabled(_ serverName: String, enabled: Bool) async { - await mutateMCP(operation: .setEnabled, serverName: serverName, enabled: enabled) - } - - private func mutateMCP( - operation: MCPMutationRequestPayload.Operation, - serverName: String, - server: MobileMCPServer? = nil, - enabled: Bool? = nil - ) async { - guard isPaired else { - lastMCPError = "Connect a Mac first." - return - } - guard !inFlightMCPMutations.contains(serverName) else { return } - let payload = MCPMutationRequestPayload( - operation: operation, - serverName: serverName, - server: server, - enabled: enabled - ) - inFlightMCPMutations.insert(serverName) - lastMCPError = nil - do { - try await client.send(.mcpMutationRequest(payload), toHex: pairedDesktopPubkey) - scheduleTimeout(Self.remoteConfigTimeout) { s in - if s.inFlightMCPMutations.remove(serverName) != nil { - s.lastMCPError = "Request timed out. Check your Mac and try again." - } - } - } catch { - inFlightMCPMutations.remove(serverName) - lastMCPError = "Failed to send request: \(error.localizedDescription)" - } - } - - // MARK: - Inbound events - - private func handle(_ event: RelayClient.Event) { - switch event { - case .stateChanged(let state): - logger.info("[Relay] connection state changed: \(String(describing: state), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") - let previous = connectionState - connectionState = state - triggerConnectionFeedback(from: previous, to: state) - if case .connected = state, isPaired { - Task { await self.requestSnapshot(reason: "relay_connected") } - } - case .deliveryFailed(let toHex): - logger.warning("[Relay] delivery failed to desktopKey=\(String(toHex.prefix(12)), privacy: .public)") - case .inbound(let inbound): - handleInbound(inbound) - } - } - - private func handleInbound(_ inbound: RelayClient.Inbound) { - switch inbound.payload { - case .pairAck(let ack): - pairingTimeoutTask?.cancel() - pairingTimeoutTask = nil - if ack.accepted { - upsertPairedDesktop( - PairedDesktop( - pubkeyHex: inbound.fromHex, - displayName: ack.desktopName, - pairedAt: .now, - lastSeen: .now - ) - ) - setActiveDesktop(pubkeyHex: inbound.fromHex) - pairingStatus = .idle - logger.info("[Pairing] accepted desktop=\(ack.desktopName, privacy: .public) desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - MobileHaptics.connected() - savePairedDesktops() - Task { - await self.requestSnapshot() - await self.reportAPNsTokenIfPending() - } - } else { - failPairing("Your Mac declined the pairing request.") - } - case .unpair: - guard let desktop = pairedDesktops.first(where: { $0.pubkeyHex == inbound.fromHex }) else { return } - Task { await self.removePairedDesktopAfterRemoteUnpair(desktop) } - case .snapshot(let snap): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "snapshot") else { return } - let profileProjectCount = snap.runProfiles?.count ?? 0 - let profileTotal = snap.runProfiles?.reduce(0) { $0 + $1.profiles.count } ?? 0 - logger.info("[MobileSync] applying snapshot projects=\(snap.projects.count, privacy: .public) sessions=\(snap.sessions.count, privacy: .public) runProfileProjects=\(profileProjectCount, privacy: .public) runProfileTotal=\(profileTotal, privacy: .public) runTasks=\(snap.runTasks?.count ?? 0, privacy: .public) from desktopKey=\(String(inbound.fromHex.prefix(12)), privacy: .public)") - projects = snap.projects - sessions = snap.sessions - branchBriefings = snap.branchBriefings ?? [] - threadSummaries = snap.threadSummaries ?? [] - desktopSettings = snap.settings - desktopUsage = snap.usage - desktopHostMetrics = snap.hostMetrics - desktopWebProxy = snap.webProxy - if let webProxy = snap.webProxy { - logger.info("[WebBrowserSync] snapshot web proxy host=\(webProxy.host, privacy: .public) port=\(webProxy.port, privacy: .public)") - } else { - logger.warning("[WebBrowserSync] snapshot missing web proxy info") - } - if let runProfiles = snap.runProfiles { - runProfilesByProject = Dictionary( - uniqueKeysWithValues: runProfiles.map { ($0.projectId, $0.profiles) } - ) - } - if let tasks = snap.runTasks { - runTasks = tasks.sorted { $0.startedAt > $1.startedAt } - } - if let branches = snap.projectBranches { - projectBranches = Dictionary(uniqueKeysWithValues: branches.map { ($0.projectId, $0.currentBranch) }) - availableBranchesByProject = Dictionary( - uniqueKeysWithValues: branches.compactMap { info -> (UUID, [String])? in - guard let list = info.availableBranches else { return nil } - return (info.projectId, list) - } - ) - } - if let active = snap.activeSessionID { - if let messages = snap.activeSessionMessages { - // The snapshot carries only the most recent page; replacing - // the window resets paging to that page. - messagesBySession[active] = messages - if snap.activeSessionHasMore == true { - sessionsWithMoreMessages.insert(active) - } else { - sessionsWithMoreMessages.remove(active) - } - loadingMoreSessions.remove(active) - } else if messagesBySession[active] == nil { - messagesBySession[active] = [] - } - activeSessionID = active - } - refreshWidgetData() - case .moreMessages(let page): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "more_messages") else { return } - applyMoreMessages(page) - case .sessionUpdate(let update): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "session_update") else { return } - applySessionUpdate(update) - refreshWidgetData() - case .permissionRequest(let req): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "permission_request") else { return } - pendingPermission = req - MobileHaptics.attentionNeeded() - case .questionQueue(let queue): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "question_queue") else { return } - let grew = queue.questions.count > pendingQuestions.count - pendingQuestions = queue.questions - if grew { MobileHaptics.attentionNeeded() } - case .notification: - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "notification") else { return } - // Foreground notifications arriving over WS — iOS won't show a - // banner automatically; UI surfaces these in a toast/badge. - break - case .searchResults(let results): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "search_results") else { return } - guard let pending = pendingSearchID, results.clientRequestID == pending else { return } - searchProjectIDs = results.projectIDs - searchThreadHits = results.threadHits - isSearching = false - case .threadChangesResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "thread_changes_result") else { return } - guard let pending = pendingThreadChangesID, result.clientRequestID == pending else { return } - pendingThreadChangesID = nil - isLoadingThreadChanges = false - threadChanges = result - case .branchOpResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "branch_op_result") else { return } - inFlightBranchOps.remove(result.clientRequestID) - if !result.ok { - lastBranchOpError = result.errorMessage ?? "Branch operation failed." - } - case .folderTreeResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "folder_tree_result") else { return } - guard pendingFolderTreeRequestID == result.clientRequestID else { return } - pendingFolderTreeRequestID = nil - remoteFolderIsLoading = false - if result.ok, let root = result.root { - remoteFolderRoot = root - remoteFolderError = nil - } else { - remoteFolderError = result.errorMessage ?? "Failed to load folders." - } - case .createProjectResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "create_project_result") else { return } - guard pendingCreateProjectRequestID == result.clientRequestID else { return } - pendingCreateProjectRequestID = nil - remoteProjectCreateInFlight = false - if result.ok, let project = result.project { - if !projects.contains(where: { $0.id == project.id }) { - projects.append(project) - } - lastCreatedProjectID = project.id - remoteProjectCreateError = nil - Task { await self.requestSnapshot() } - } else { - remoteProjectCreateError = result.errorMessage ?? "Failed to add project." - } - case .runProfileResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_profile_result") else { return } - logger.info("[RunProfiles] received result id=\(result.clientRequestID.uuidString, privacy: .public) ok=\(result.ok, privacy: .public) project=\(result.projectID.uuidString, privacy: .public) profiles=\(result.profiles?.count ?? 0, privacy: .public) task=\(result.task?.taskId.uuidString ?? "", privacy: .public) error=\(result.errorMessage ?? "", privacy: .public)") - applyRunProfileResult(result) - case .runTaskUpdate(let update): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "run_task_update") else { return } - upsertRunTask(update.task) - case .skillCatalogResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_catalog_result") else { return } - applySkillCatalogResult(result) - case .skillMutationResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_mutation_result") else { return } - applySkillMutationResult(result) - case .skillSourceMutationResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "skill_source_mutation_result") else { return } - applySkillSourceMutationResult(result) - case .acpRegistryResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_registry_result") else { return } - applyACPRegistryResult(result) - case .acpMutationResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "acp_mutation_result") else { return } - applyACPMutationResult(result) - case .mcpConfigResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_config_result") else { return } - applyMCPConfigResult(result) - case .mcpMutationResult(let result): - guard acceptsActiveDesktopPayload(from: inbound.fromHex, type: "mcp_mutation_result") else { return } - applyMCPMutationResult(result) - case .ping: - guard pairedDesktops.contains(where: { $0.pubkeyHex == inbound.fromHex }) else { return } - Task { try? await self.client.send(.pong(PongPayload()), toHex: inbound.fromHex) } - default: - break - } - } - - private func applyRunProfileResult(_ result: RunProfileResultPayload) { - inFlightRunProfileRequests.remove(result.clientRequestID) - if let profiles = result.profiles { - runProfilesByProject[result.projectID] = profiles - logger.info("[RunProfiles] applied result profiles project=\(result.projectID.uuidString, privacy: .public) count=\(profiles.count, privacy: .public)") - } - if let task = result.task { - upsertRunTask(task) - } - if result.ok { - lastRunProfileError = nil - Task { await self.requestSnapshot() } - } else { - lastRunProfileError = result.errorMessage ?? "Run profile operation failed." - } - } - - private func applySkillCatalogResult(_ result: SkillCatalogResultPayload) { - guard pendingSkillCatalogRequestID == result.clientRequestID else { return } - pendingSkillCatalogRequestID = nil - skillCatalogLoading = false - if result.ok { - skillCatalog = result.plugins - skillSources = result.sources - skillCatalogError = nil - } else { - skillCatalogError = result.errorMessage ?? "Failed to load skills." - } - } - - private func applySkillMutationResult(_ result: SkillMutationResultPayload) { - inFlightSkillMutations.remove(result.pluginID) - skillCatalog = result.plugins - skillSources = result.sources - if result.ok { - lastSkillError = nil - } else { - lastSkillError = result.errorMessage ?? "Skill operation failed." - } - } - - private func applySkillSourceMutationResult(_ result: SkillSourceMutationResultPayload) { - if let key = skillSourceMutationKeys.removeValue(forKey: result.clientRequestID) { - inFlightSkillSourceMutations.remove(key) - } - if let sourceID = result.sourceID { - inFlightSkillSourceMutations.remove(sourceID) - } - skillCatalog = result.plugins - skillSources = result.sources - if result.ok { - lastSkillError = nil - } else { - lastSkillError = result.errorMessage ?? "Skill source operation failed." - } - } - - private func applyACPRegistryResult(_ result: ACPRegistryResultPayload) { - guard pendingACPRegistryRequestID == result.clientRequestID else { return } - pendingACPRegistryRequestID = nil - acpRegistryLoading = false - if result.ok { - acpRegistryAgents = result.registryAgents - acpInstalledClients = result.installedClients - acpRegistryError = nil - } else { - acpRegistryError = result.errorMessage ?? "Failed to load the agent registry." - } - } - - private func applyACPMutationResult(_ result: ACPMutationResultPayload) { - if let key = acpMutationKeys.removeValue(forKey: result.clientRequestID) { - inFlightACPMutations.remove(key) - } - acpRegistryAgents = result.registryAgents - acpInstalledClients = result.installedClients - if result.ok { - lastACPError = nil - } else { - lastACPError = result.errorMessage ?? "Agent operation failed." - } - } - - private func applyMCPConfigResult(_ result: MCPConfigResultPayload) { - guard pendingMCPConfigRequestID == result.clientRequestID else { return } - pendingMCPConfigRequestID = nil - mcpConfigLoading = false - if result.ok { - mcpServers = result.servers - mcpConfigError = nil - } else { - mcpConfigError = result.errorMessage ?? "Failed to load MCP servers." - } - } - - private func applyMCPMutationResult(_ result: MCPMutationResultPayload) { - inFlightMCPMutations.remove(result.serverName) - mcpServers = result.servers - if result.ok { - lastMCPError = nil - } else { - lastMCPError = result.errorMessage ?? "MCP operation failed." - } - } - - private func upsertRunTask(_ task: MobileRunTaskSnapshot) { - if let idx = runTasks.firstIndex(where: { $0.taskId == task.taskId }) { - runTasks[idx] = task - } else { - runTasks.insert(task, at: 0) - } - runTasks.sort { $0.startedAt > $1.startedAt } - } - - private func applySessionUpdate(_ update: SessionUpdatePayload) { - if let previous = update.previousSessionID, previous != update.sessionID { - if let carried = messagesBySession.removeValue(forKey: previous) { - if let existing = messagesBySession[update.sessionID], !existing.isEmpty { - // The new session id already accumulated live messages - // before the redirect landed. Prepend the carried history, - // deduped by id, so the older messages aren't dropped. - let existingIDs = Set(existing.map(\.id)) - messagesBySession[update.sessionID] = - carried.filter { !existingIDs.contains($0.id) } + existing - } else { - messagesBySession[update.sessionID] = carried - } - } - if sessionsWithMoreMessages.remove(previous) != nil { - sessionsWithMoreMessages.insert(update.sessionID) - } - if loadingMoreSessions.remove(previous) != nil { - loadingMoreSessions.insert(update.sessionID) - } - if activeSessionID == previous { - activeSessionID = update.sessionID - } - sessions.removeAll { $0.id == previous } - } - - if let summary = update.summary { - upsertSessionSummary(summary) - } else if let isStreaming = update.isStreaming { - updateSessionStreamingFlag(sessionID: update.sessionID, isStreaming: isStreaming) - } - - if let isThinking = update.isThinking { - setSessionThinking(sessionID: update.sessionID, isThinking: isThinking) - } - // A session that is no longer streaming cannot still be thinking. - if update.isStreaming == false { - thinkingSessions.remove(update.sessionID) - } - - switch update.kind { - case .messageAppended: - if let m = update.message { - messagesBySession[update.sessionID, default: []].append(m) - } - case .messageUpdated: - if let m = update.message, - var list = messagesBySession[update.sessionID], - let idx = list.firstIndex(where: { $0.id == m.id }) { - list[idx] = m - messagesBySession[update.sessionID] = list - } - case .streamingFinished: - thinkingSessions.remove(update.sessionID) - // Soft success cue, but only when the user is actually looking at - // (or last looked at) the session that just finished. Avoids - // buzzing on background-session completions. - if update.sessionID == activeSessionID { - MobileHaptics.streamFinished() - } - case .streamingStarted, .statusChanged: - // Surface as a flag on the relevant session row. - break - } - } - - private func upsertSessionSummary(_ summary: SessionSummary) { - if let index = sessions.firstIndex(where: { $0.id == summary.id }) { - sessions[index] = summary - } else { - sessions.append(summary) - } - sessions.sort { lhs, rhs in - if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned } - return lhs.updatedAt > rhs.updatedAt - } - } - - private func updateSessionStreamingFlag(sessionID: String, isStreaming: Bool) { - guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else { return } - let current = sessions[index] - sessions[index] = SessionSummary( - id: current.id, - projectId: current.projectId, - title: current.title, - updatedAt: current.updatedAt, - isPinned: current.isPinned, - isArchived: current.isArchived, - isStreaming: isStreaming, - attention: current.attention, - progress: current.progress, - todos: current.todos, - queuedMessages: current.queuedMessages, - hasUncheckedCompletion: current.hasUncheckedCompletion - ) - } - - private func setSessionThinking(sessionID: String, isThinking: Bool) { - if isThinking { - thinkingSessions.insert(sessionID) - } else { - thinkingSessions.remove(sessionID) - } - } - - func refreshFromDesktop(reason: String) async { - await requestSnapshot(reason: reason) - } - - private func requestSnapshot(reason: String = "manual") async { - guard isPaired else { - logger.info("[MobileSync] snapshot request skipped reason=\(reason, privacy: .public): mobile is not paired") - return - } - let payload = RequestSnapshotPayload(activeSessionID: activeSessionID) - do { - try await client.send(.requestSnapshot(payload), toHex: pairedDesktopPubkey) - logger.info("[MobileSync] requested snapshot reason=\(reason, privacy: .public) activeSession=\(self.activeSessionID ?? "", privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public)") - } catch { - logger.error("[MobileSync] snapshot request failed reason=\(reason, privacy: .public) desktopKey=\(String(self.pairedDesktopPubkey.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - - private func failPairing(_ message: String) { - pairingStatus = .failed(message) - MobileHaptics.connectionError() - } - - private func triggerConnectionFeedback( - from previous: RelayClient.ConnectionState, - to next: RelayClient.ConnectionState - ) { - guard previous != next else { return } - guard isPaired, pairingStatus != .inProgress else { return } - - switch next { - case .connected: - if case .reconnecting = previous { - MobileHaptics.connected() - } - case .reconnecting: - if previous == .connected { - MobileHaptics.connectionError() - } - case .disconnected: - if previous == .connected { - MobileHaptics.connectionError() - } - case .connecting: - break - } - } - - private func reportAPNsTokenIfPending() async { - guard !pairedDesktops.isEmpty else { - logger.info("[APNs] token report pending: mobile is not paired") - return - } - guard let tokenHex = apnsTokenHex else { - logger.info("[APNs] token report pending: no APNs token yet") - return - } - guard let env = apnsEnvironment else { - logger.info("[APNs] token report pending: no APNs environment yet") - return - } - let payload = APNsTokenPayload(tokenHex: tokenHex, environment: env) - for desktop in pairedDesktops { - do { - try await client.send(.apnsToken(payload), toHex: desktop.pubkeyHex) - logger.info("[APNs] token reported to desktop tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(env, privacy: .public) desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - } catch { - logger.error("[APNs] token report failed desktopKey=\(String(desktop.pubkeyHex.prefix(12)), privacy: .public) mobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - } - - private func applySettingsUpdateLocally(_ update: MobileSettingsUpdatePayload) { - guard let current = desktopSettings else { return } - // Optimistically reflect a provider change, resolving its display name - // from the synced options so the picker label updates immediately. - let summarizationProvider = update.summarizationProvider ?? current.summarizationProvider - let summarizationProviderDisplayName = update.summarizationProvider.flatMap { raw in - current.availableSummarizationProviders?.first(where: { $0.id == raw })?.displayName - } ?? current.summarizationProviderDisplayName - desktopSettings = MobileSettingsSnapshot( - selectedAgentProvider: update.selectedAgentProvider ?? current.selectedAgentProvider, - selectedModel: update.selectedModel ?? current.selectedModel, - selectedACPClientId: update.selectedACPClientId ?? current.selectedACPClientId, - selectedEffort: update.selectedEffort ?? current.selectedEffort, - permissionMode: update.permissionMode ?? current.permissionMode, - summarizationProvider: summarizationProvider, - summarizationProviderDisplayName: summarizationProviderDisplayName, - openAISummarizationEndpoint: current.openAISummarizationEndpoint, - openAISummarizationModel: update.openAISummarizationModel ?? current.openAISummarizationModel, - notificationsEnabled: update.notificationsEnabled ?? current.notificationsEnabled, - focusMode: update.focusMode ?? current.focusMode, - autoArchiveEnabled: update.autoArchiveEnabled ?? current.autoArchiveEnabled, - archiveRetentionDays: update.archiveRetentionDays ?? current.archiveRetentionDays, - autoPreviewSettings: update.autoPreviewSettings ?? current.autoPreviewSettings, - availableEfforts: current.availableEfforts, - availableModels: current.availableModels, - modelSections: current.modelSections, - availableSummarizationProviders: current.availableSummarizationProviders, - openAISummarizationModels: current.openAISummarizationModels - ) - } - - // MARK: - Persistence - - private func loadPairedDesktops() { - let defaults = UserDefaults.standard - let savedMobilePubkey = defaults.string(forKey: Self.mobilePubkeyKey) - if let savedMobilePubkey, - !savedMobilePubkey.isEmpty, - savedMobilePubkey != identity.publicKeyHex { - logger.warning("[Pairing] clearing stale saved desktop pairing savedMobileKey=\(String(savedMobilePubkey.prefix(12)), privacy: .public) currentMobileKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)") - pairedDesktops = [] - setActiveDesktop(pubkeyHex: nil) - savePairedDesktops() - return - } - - if let data = defaults.data(forKey: Self.pairedDesktopsKey), - let decoded = try? JSONDecoder().decode([PairedDesktop].self, from: data) { - pairedDesktops = Self.deduplicate(decoded) - } else { - let legacyPubkey = defaults.string(forKey: Self.legacyDesktopPubkeyKey) ?? "" - let legacyName = defaults.string(forKey: Self.legacyDesktopNameKey) ?? "" - if !legacyPubkey.isEmpty { - pairedDesktops = [ - PairedDesktop( - pubkeyHex: legacyPubkey, - displayName: legacyName, - pairedAt: .now, - lastSeen: nil - ) - ] - } - } - - let preferred = defaults.string(forKey: Self.activeDesktopPubkeyKey) - ?? defaults.string(forKey: Self.legacyDesktopPubkeyKey) - setActiveDesktop(pubkeyHex: preferred) - if !pairedDesktops.isEmpty { - savePairedDesktops() - } - } - - private func savePairedDesktops() { - let defaults = UserDefaults.standard - if pairedDesktops.isEmpty { - defaults.removeObject(forKey: Self.pairedDesktopsKey) - defaults.removeObject(forKey: Self.activeDesktopPubkeyKey) - defaults.removeObject(forKey: Self.legacyDesktopPubkeyKey) - defaults.removeObject(forKey: Self.legacyDesktopNameKey) - defaults.removeObject(forKey: Self.mobilePubkeyKey) - } else { - let encoder = JSONEncoder() - if let data = try? encoder.encode(pairedDesktops) { - defaults.set(data, forKey: Self.pairedDesktopsKey) - } - defaults.set(pairedDesktopPubkey, forKey: Self.activeDesktopPubkeyKey) - defaults.set(pairedDesktopPubkey, forKey: Self.legacyDesktopPubkeyKey) - defaults.set(pairedDesktopName, forKey: Self.legacyDesktopNameKey) - defaults.set(identity.publicKeyHex, forKey: Self.mobilePubkeyKey) - } - } - - private func setActiveDesktop(pubkeyHex: String?) { - let resolved = pubkeyHex.flatMap { requested in - pairedDesktops.first { $0.pubkeyHex == requested } - } ?? pairedDesktops.first - - pairedDesktopPubkey = resolved?.pubkeyHex ?? "" - pairedDesktopName = resolved?.displayName ?? "" - isPaired = resolved != nil - } - - private func upsertPairedDesktop(_ desktop: PairedDesktop) { - if let index = pairedDesktops.firstIndex(where: { $0.pubkeyHex == desktop.pubkeyHex }) { - let existing = pairedDesktops[index] - pairedDesktops[index] = PairedDesktop( - pubkeyHex: desktop.pubkeyHex, - displayName: desktop.displayName, - pairedAt: existing.pairedAt, - lastSeen: desktop.lastSeen ?? existing.lastSeen - ) - } else { - pairedDesktops.append(desktop) - } - } - - private func acceptsActiveDesktopPayload(from pubkeyHex: String, type: String) -> Bool { - guard pubkeyHex == pairedDesktopPubkey else { - logger.info("[Pairing] ignoring \(type, privacy: .public) from inactive desktopKey=\(String(pubkeyHex.prefix(12)), privacy: .public)") - return false - } - return true - } - - private func removePairedDesktopAfterRemoteUnpair(_ desktop: PairedDesktop) async { - let wasActive = desktop.pubkeyHex == pairedDesktopPubkey - pairedDesktops.removeAll { $0.pubkeyHex == desktop.pubkeyHex } - await client.removePeer(desktop.pubkeyHex) - if wasActive { - clearDesktopMirror() - setActiveDesktop(pubkeyHex: pairedDesktops.first?.pubkeyHex) - } else { - setActiveDesktop(pubkeyHex: pairedDesktopPubkey) - } - savePairedDesktops() - if wasActive, isPaired { - await requestSnapshot() - await reportAPNsTokenIfPending() - } - } - - private static func deduplicate(_ desktops: [PairedDesktop]) -> [PairedDesktop] { - var seen: Set = [] - var result: [PairedDesktop] = [] - for desktop in desktops { - guard !desktop.pubkeyHex.isEmpty, !seen.contains(desktop.pubkeyHex) else { continue } - seen.insert(desktop.pubkeyHex) - result.append(desktop) - } - return result - } } @MainActor diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index b5176db..a3b78d3 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -703,373 +703,3 @@ struct MobileChatView: View { return count == 1 ? "Plan ready to review" : "\(count) plans ready to review" } } - -// MARK: - Streaming Indicator - -/// Loading indicator shown at the end of the message list while the agent is -/// generating a response — mirrors the desktop `StreamingIndicatorView`. Shows -/// a "Thinking…" label while the agent is producing reasoning tokens, and three -/// bouncing dots throughout. -private struct MobileStreamingIndicator: View { - var isThinking: Bool = false - @State private var phase: Int = 0 - private let timer = Timer.publish(every: 0.18, on: .main, in: .common).autoconnect() - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - if isThinking { - HStack(spacing: 6) { - Image(systemName: "sparkles") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(ClaudeTheme.textTertiary) - Text("Thinking") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(ClaudeTheme.textSecondary) - } - .transition(.opacity) - } - dots - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .animation(.easeInOut(duration: 0.2), value: isThinking) - .onReceive(timer) { _ in - phase = (phase + 1) % 3 - } - .accessibilityLabel(isThinking ? "Thinking" : "Response in progress") - } - - private var dots: some View { - HStack(spacing: 5) { - ForEach(0 ..< 3, id: \.self) { i in - Circle() - .fill(ClaudeTheme.textTertiary) - .frame(width: 7, height: 7) - .opacity(phase == i ? 1.0 : 0.3) - .scaleEffect(phase == i ? 1.0 : 0.85) - .animation(.easeInOut(duration: 0.25), value: phase) - } - } - } -} - -// MARK: - Rename Thread Sheet - -/// Compact modal that captures a new thread title. Commits the trimmed value -/// via `onSubmit` and dismisses; an empty title is treated as a no-op. -private struct RenameThreadSheet: View { - let currentTitle: String - let onSubmit: (String) -> Void - @Environment(\.dismiss) private var dismiss - @State private var text: String - @FocusState private var isFocused: Bool - - init(currentTitle: String, onSubmit: @escaping (String) -> Void) { - self.currentTitle = currentTitle - self.onSubmit = onSubmit - _text = State(initialValue: currentTitle) - } - - private var trimmed: String { - text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canSave: Bool { - !trimmed.isEmpty && trimmed != currentTitle - } - - var body: some View { - NavigationStack { - Form { - Section("Thread name") { - TextField("Thread name", text: $text) - .focused($isFocused) - .submitLabel(.done) - .onSubmit(commit) - } - } - .navigationTitle("Rename Thread") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .topBarTrailing) { - Button("Save") { commit() } - .fontWeight(.semibold) - .disabled(!canSave) - } - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - isFocused = true - } - } - } - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - - private func commit() { - guard canSave else { return } - onSubmit(trimmed) - dismiss() - } -} - -// MARK: - Queued Messages Sheet - -private struct QueuedMessagesSheet: View { - let messages: [QueuedUserMessage] - let onRemove: (QueuedUserMessage) -> Void - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - Group { - if messages.isEmpty { - ContentUnavailableView( - "No Queued Messages", - systemImage: "tray", - description: Text("Messages you queue while a response is streaming appear here.") - ) - } else { - List { - ForEach(messages) { queued in - VStack(alignment: .leading, spacing: 0) { - Text(queued.text) - .font(.body) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical, 4) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - onRemove(queued) - } label: { - Label("Remove", systemImage: "trash") - } - } - } - } - .listStyle(.plain) - } - } - .navigationTitle("Queued Messages") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - } - } - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } -} - -// MARK: - Todo Progress Indicator - -/// Compact progress ring shown beside the navigation title while a thread has -/// todos. Mirrors the desktop `TodoProgressPill`: a determinate ring filling as -/// todos complete, accented while work is in progress and green once done. -private struct MobileTodoProgressIndicator: View { - let done: Int - let total: Int - let inProgress: Bool - - private var isComplete: Bool { total > 0 && done == total } - - private var fraction: Double { - guard total > 0 else { return 0 } - return min(1, max(0, Double(done) / Double(total))) - } - - private var accent: Color { - if isComplete { return ClaudeTheme.statusSuccess } - if inProgress { return ClaudeTheme.accent } - return .secondary - } - - var body: some View { - HStack(spacing: 5) { - ZStack { - Circle() - .stroke(accent.opacity(0.22), lineWidth: 2) - Circle() - .trim(from: 0, to: fraction) - .stroke(accent, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.25), value: fraction) - } - .frame(width: 15, height: 15) - - Text("\(done)/\(total)") - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.primary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(Color.secondary.opacity(0.14))) - .contentShape(Capsule()) - } -} - -// MARK: - Thread Todo Sheet - -/// Bottom sheet listing the thread's todo items and its generated summary, -/// mirroring the desktop's todo popover. Opened from the navigation-title -/// progress indicator. -private struct ThreadTodoSheet: View { - let threadTitle: String - let todos: [TodoItem] - let summary: MobileThreadSummary? - @Environment(\.dismiss) private var dismiss - - private var doneCount: Int { - todos.filter { $0.status == .completed }.count - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - titleHeader - if !todos.isEmpty { - todoSection - } - summarySection - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - } - .navigationTitle(todos.isEmpty ? "Thread Summary" : "Todos & Summary") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - } - } - } - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - - // MARK: Title header - - private var titleHeader: some View { - Text(threadTitle) - .font(.title3.weight(.semibold)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - - // MARK: Todo section - - @ViewBuilder - private var todoSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "checklist") - .font(.caption) - .foregroundStyle(.secondary) - Text("Todos") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Spacer() - Text("\(doneCount)/\(todos.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - } - - if todos.isEmpty { - Text("No todos for this thread.") - .font(.body) - .foregroundStyle(.tertiary) - .italic() - } else { - VStack(alignment: .leading, spacing: 10) { - ForEach(todos) { todo in - todoRow(todo) - } - } - } - } - } - - private func todoRow(_ todo: TodoItem) -> some View { - HStack(alignment: .top, spacing: 10) { - statusIcon(todo.status) - .font(.system(size: 17)) - .frame(width: 22) - Text(label(for: todo)) - .font(.callout) - .foregroundStyle(textColor(todo.status)) - .strikethrough(todo.status == .completed, color: .secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - } - - @ViewBuilder - private func statusIcon(_ status: TodoItem.Status) -> some View { - switch status { - case .pending: - Image(systemName: "circle") - .foregroundStyle(.tertiary) - case .inProgress: - Image(systemName: "arrow.right.circle.fill") - .foregroundStyle(ClaudeTheme.accent) - case .completed: - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(ClaudeTheme.statusSuccess) - } - } - - private func label(for todo: TodoItem) -> String { - todo.status == .inProgress && !todo.activeForm.isEmpty ? todo.activeForm : todo.content - } - - private func textColor(_ status: TodoItem.Status) -> Color { - switch status { - case .pending: return .secondary - case .inProgress: return .primary - case .completed: return .secondary - } - } - - // MARK: Summary section - - @ViewBuilder - private var summarySection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Image(systemName: "text.alignleft") - .font(.caption) - .foregroundStyle(.secondary) - Text("Summary") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - } - - if let summary, !summary.summary.isEmpty { - ChatTextContentView( - markdown: summary.summary, - size: 15, - color: .primary, - lineSpacing: 3 - ) - } else { - Text("No summary yet. A summary is generated once the thread finishes a turn on your Mac.") - .font(.body) - .foregroundStyle(.tertiary) - .italic() - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/RxCodeMobile/Views/MobileStreamingIndicator.swift b/RxCodeMobile/Views/MobileStreamingIndicator.swift new file mode 100644 index 0000000..282159b --- /dev/null +++ b/RxCodeMobile/Views/MobileStreamingIndicator.swift @@ -0,0 +1,54 @@ +import Combine +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Streaming Indicator + +/// Loading indicator shown at the end of the message list while the agent is +/// generating a response — mirrors the desktop `StreamingIndicatorView`. Shows +/// a "Thinking…" label while the agent is producing reasoning tokens, and three +/// bouncing dots throughout. +struct MobileStreamingIndicator: View { + var isThinking: Bool = false + @State private var phase: Int = 0 + private let timer = Timer.publish(every: 0.18, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if isThinking { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ClaudeTheme.textTertiary) + Text("Thinking") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ClaudeTheme.textSecondary) + } + .transition(.opacity) + } + dots + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(.easeInOut(duration: 0.2), value: isThinking) + .onReceive(timer) { _ in + phase = (phase + 1) % 3 + } + .accessibilityLabel(isThinking ? "Thinking" : "Response in progress") + } + + private var dots: some View { + HStack(spacing: 5) { + ForEach(0 ..< 3, id: \.self) { i in + Circle() + .fill(ClaudeTheme.textTertiary) + .frame(width: 7, height: 7) + .opacity(phase == i ? 1.0 : 0.3) + .scaleEffect(phase == i ? 1.0 : 0.85) + .animation(.easeInOut(duration: 0.25), value: phase) + } + } + } +} diff --git a/RxCodeMobile/Views/QueuedMessagesSheet.swift b/RxCodeMobile/Views/QueuedMessagesSheet.swift new file mode 100644 index 0000000..7e430cd --- /dev/null +++ b/RxCodeMobile/Views/QueuedMessagesSheet.swift @@ -0,0 +1,56 @@ +import Combine +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Queued Messages Sheet + +struct QueuedMessagesSheet: View { + let messages: [QueuedUserMessage] + let onRemove: (QueuedUserMessage) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if messages.isEmpty { + ContentUnavailableView( + "No Queued Messages", + systemImage: "tray", + description: Text("Messages you queue while a response is streaming appear here.") + ) + } else { + List { + ForEach(messages) { queued in + VStack(alignment: .leading, spacing: 0) { + Text(queued.text) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 4) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + onRemove(queued) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + .listStyle(.plain) + } + } + .navigationTitle("Queued Messages") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} diff --git a/RxCodeMobile/Views/RenameThreadSheet.swift b/RxCodeMobile/Views/RenameThreadSheet.swift new file mode 100644 index 0000000..3160442 --- /dev/null +++ b/RxCodeMobile/Views/RenameThreadSheet.swift @@ -0,0 +1,69 @@ +import Combine +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Rename Thread Sheet + +/// Compact modal that captures a new thread title. Commits the trimmed value +/// via `onSubmit` and dismisses; an empty title is treated as a no-op. +struct RenameThreadSheet: View { + let currentTitle: String + let onSubmit: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var text: String + @FocusState private var isFocused: Bool + + init(currentTitle: String, onSubmit: @escaping (String) -> Void) { + self.currentTitle = currentTitle + self.onSubmit = onSubmit + _text = State(initialValue: currentTitle) + } + + private var trimmed: String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSave: Bool { + !trimmed.isEmpty && trimmed != currentTitle + } + + var body: some View { + NavigationStack { + Form { + Section("Thread name") { + TextField("Thread name", text: $text) + .focused($isFocused) + .submitLabel(.done) + .onSubmit(commit) + } + } + .navigationTitle("Rename Thread") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { commit() } + .fontWeight(.semibold) + .disabled(!canSave) + } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + isFocused = true + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private func commit() { + guard canSave else { return } + onSubmit(trimmed) + dismiss() + } +} diff --git a/RxCodeMobile/Views/ThreadTodoSheet.swift b/RxCodeMobile/Views/ThreadTodoSheet.swift new file mode 100644 index 0000000..b2eaa59 --- /dev/null +++ b/RxCodeMobile/Views/ThreadTodoSheet.swift @@ -0,0 +1,211 @@ +import Combine +import RxCodeChatKit +import RxCodeCore +import RxCodeSync +import SwiftUI + +// MARK: - Todo Progress Indicator + +/// Compact progress ring shown beside the navigation title while a thread has +/// todos. Mirrors the desktop `TodoProgressPill`: a determinate ring filling as +/// todos complete, accented while work is in progress and green once done. +struct MobileTodoProgressIndicator: View { + let done: Int + let total: Int + let inProgress: Bool + + private var isComplete: Bool { total > 0 && done == total } + + private var fraction: Double { + guard total > 0 else { return 0 } + return min(1, max(0, Double(done) / Double(total))) + } + + private var accent: Color { + if isComplete { return ClaudeTheme.statusSuccess } + if inProgress { return ClaudeTheme.accent } + return .secondary + } + + var body: some View { + HStack(spacing: 5) { + ZStack { + Circle() + .stroke(accent.opacity(0.22), lineWidth: 2) + Circle() + .trim(from: 0, to: fraction) + .stroke(accent, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.25), value: fraction) + } + .frame(width: 15, height: 15) + + Text("\(done)/\(total)") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.secondary.opacity(0.14))) + .contentShape(Capsule()) + } +} + +// MARK: - Thread Todo Sheet + +/// Bottom sheet listing the thread's todo items and its generated summary, +/// mirroring the desktop's todo popover. Opened from the navigation-title +/// progress indicator. +struct ThreadTodoSheet: View { + let threadTitle: String + let todos: [TodoItem] + let summary: MobileThreadSummary? + @Environment(\.dismiss) private var dismiss + + private var doneCount: Int { + todos.filter { $0.status == .completed }.count + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + titleHeader + if !todos.isEmpty { + todoSection + } + summarySection + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(todos.isEmpty ? "Thread Summary" : "Todos & Summary") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + // MARK: Title header + + private var titleHeader: some View { + Text(threadTitle) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: Todo section + + @ViewBuilder + private var todoSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "checklist") + .font(.caption) + .foregroundStyle(.secondary) + Text("Todos") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Spacer() + Text("\(doneCount)/\(todos.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + + if todos.isEmpty { + Text("No todos for this thread.") + .font(.body) + .foregroundStyle(.tertiary) + .italic() + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(todos) { todo in + todoRow(todo) + } + } + } + } + } + + private func todoRow(_ todo: TodoItem) -> some View { + HStack(alignment: .top, spacing: 10) { + statusIcon(todo.status) + .font(.system(size: 17)) + .frame(width: 22) + Text(label(for: todo)) + .font(.callout) + .foregroundStyle(textColor(todo.status)) + .strikethrough(todo.status == .completed, color: .secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private func statusIcon(_ status: TodoItem.Status) -> some View { + switch status { + case .pending: + Image(systemName: "circle") + .foregroundStyle(.tertiary) + case .inProgress: + Image(systemName: "arrow.right.circle.fill") + .foregroundStyle(ClaudeTheme.accent) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(ClaudeTheme.statusSuccess) + } + } + + private func label(for todo: TodoItem) -> String { + todo.status == .inProgress && !todo.activeForm.isEmpty ? todo.activeForm : todo.content + } + + private func textColor(_ status: TodoItem.Status) -> Color { + switch status { + case .pending: return .secondary + case .inProgress: return .primary + case .completed: return .secondary + } + } + + // MARK: Summary section + + @ViewBuilder + private var summarySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "text.alignleft") + .font(.caption) + .foregroundStyle(.secondary) + Text("Summary") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + if let summary, !summary.summary.isEmpty { + ChatTextContentView( + markdown: summary.summary, + size: 15, + color: .primary, + lineSpacing: 3 + ) + } else { + Text("No summary yet. A summary is generated once the thread finishes a turn on your Mac.") + .font(.body) + .foregroundStyle(.tertiary) + .italic() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +}