diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 30da8f2d..bb390a94 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; + 7CF2A0012F7C100100A1B2C3 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 7CF2A0032F7C100100A1B2C3 /* MarkdownUI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,6 +52,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7CF2A0012F7C100100A1B2C3 /* MarkdownUI in Frameworks */, 7C3697892ED70F9C005874CE /* DynamicNotchKit in Frameworks */, 7C5AF14B2F15041600DE21B0 /* MediaRemoteAdapter in Frameworks */, 7C3236542EAAD608007E4CB6 /* MCP in Frameworks */, @@ -157,6 +159,7 @@ 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */, 7C1C72F12EECBD1300E3BF4D /* SwiftWhisper */, 7C5AF14A2F15041600DE21B0 /* MediaRemoteAdapter */, + 7CF2A0032F7C100100A1B2C3 /* MarkdownUI */, ); productName = FluidVoice; productReference = 7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */; @@ -214,6 +217,7 @@ 7C3697872ED70F9C005874CE /* XCRemoteSwiftPackageReference "DynamicNotchKit" */, 7C1C72F02EECBD1300E3BF4D /* XCRemoteSwiftPackageReference "SwiftWhisper" */, 7C5AF1492F15041600DE21B0 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */, + 7CF2A0022F7C100100A1B2C3 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, ); preferredProjectObjectVersion = 77; productRefGroup = 7C078D902E3B339200FB7CAC /* Products */; @@ -608,7 +612,7 @@ repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.10.2; + minimumVersion = 0.12.0; }; }; 7C3697872ED70F9C005874CE /* XCRemoteSwiftPackageReference "DynamicNotchKit" */ = { @@ -635,6 +639,14 @@ minimumVersion = 1.1.1; }; }; + 7CF2A0022F7C100100A1B2C3 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -668,6 +680,11 @@ package = 7CE006BB2E80EBE600DDCCD6 /* XCRemoteSwiftPackageReference "AppUpdater" */; productName = AppUpdater; }; + 7CF2A0032F7C100100A1B2C3 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = 7CF2A0022F7C100100A1B2C3 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 7C078D872E3B339200FB7CAC /* Project object */; diff --git a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5ea70f85..1ef79256 100644 --- a/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Fluid.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "98186d72302f89f0ba2f7e7af3fb034d3d75340965f566935f17fd7196e69e57", + "originHash" : "b5ad1f8be1ca5a46b718bee2a60518cca898b3860794876cff747e04b1d2c18a", "pins" : [ { "identity" : "appupdater", @@ -55,6 +55,15 @@ "revision" : "78aae86c03adab11a7b352211cc82381737cf854" } }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, { "identity" : "path.swift", "kind" : "remoteSourceControl", @@ -82,6 +91,24 @@ "version" : "1.6.0" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -127,13 +154,31 @@ "version" : "1.6.4" } }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a", + "version" : "2.97.1" + } + }, { "identity" : "swift-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", "state" : { - "revision" : "c0407a0b52677cb395d824cac2879b963075ba8c", - "version" : "0.10.2" + "revision" : "6132fd4b5b4217ce4717c4775e4607f5c3120129", + "version" : "0.12.0" } }, { diff --git a/README.md b/README.md index 710bf570..6c877c96 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,31 @@ https://github.com/user-attachments/assets/c57ef6d5-f0a1-4a3f-a121-637533442c24 - **Auto-updates** with seamless restart - **Opt-in beta channel** for early preview builds +## MCP in Command Mode + +FluidVoice supports MCP tools in **Command Mode** via OpenAI-compatible tool calling. + +- MCP config file: `~/Library/Application Support/FluidVoice/settings.json` +- Edit it in-app from **Command Mode header → MCP menu → Edit settings.json** +- Open it from **Command Mode header → MCP menu → Open settings.json** +- Saving in-app validates JSON and auto-reloads MCP servers +- Config shape is Claude-style: top-level `mcpServers` object +- Enable or disable servers with each server's optional `enabled` field (`true` by default) +- Supported transports in config: `stdio` and `http` (`type` is optional for stdio) + +Example config: + +```json +{ + "mcpServers": { + "altic-mcp": { + "command": "uv", + "args": ["run", "--project", "/FULL/PATH/TO/altic-mcp", "/FULL/PATH/TO/altic-mcp/server.py"] + } + } +} +``` + ## Supported Models | Model | Best for | Language support | Download size | Hardware | diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index e6b8da20..4a744125 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -312,14 +312,12 @@ struct ContentView: View { DebugLogger.shared.info("✅ Audio subsystems initialized", source: "ContentView") } - // Set up notch click callback for expanding command conversation + // Set up notch click callback for navigating to in-app Command Mode NotchOverlayManager.shared.onNotchClicked = { guard NotchOverlayManager.shared.canHandleNotchCommandTap else { return } - // When notch is clicked in command mode, show expanded conversation - if NotchOverlayManager.shared.canShowExpandedCommandOutput, - !NotchContentState.shared.commandConversationHistory.isEmpty - { - NotchOverlayManager.shared.showExpandedCommandOutput() + if !NotchContentState.shared.commandConversationHistory.isEmpty { + NotchOverlayManager.shared.hide() + self.menuBarManager.openCommandModeFromUI() } } @@ -805,6 +803,8 @@ struct ContentView: View { switch destination { case .preferences: self.selectedSidebarItem = .preferences + case .commandMode: + self.selectedSidebarItem = .commandMode } } @@ -1730,6 +1730,7 @@ struct ContentView: View { // Thinking tokens are extracted but not displayed (no onThinkingChunk) let config = LLMClient.Config( messages: messages, + providerID: currentSelectedProviderID, model: derivedSelectedModel, baseURL: derivedBaseURL, apiKey: apiKey, diff --git a/Sources/Fluid/Persistence/ChatHistoryStore.swift b/Sources/Fluid/Persistence/ChatHistoryStore.swift index fd73c9cf..755d58e6 100644 --- a/Sources/Fluid/Persistence/ChatHistoryStore.swift +++ b/Sources/Fluid/Persistence/ChatHistoryStore.swift @@ -16,6 +16,8 @@ struct ChatMessage: Codable, Identifiable, Equatable { let content: String let toolCall: ToolCall? let stepType: StepType + let renderIntent: RenderIntent + let sourceToolCallID: String? let timestamp: Date enum Role: String, Codable, Equatable { @@ -34,21 +36,179 @@ struct ChatMessage: Codable, Identifiable, Equatable { case failure } + enum RenderIntent: String, Codable, Equatable { + case userText + case assistantText + case toolInvocation + case toolResult + case status + } + + private enum CodingKeys: String, CodingKey { + case id + case role + case content + case toolCall + case stepType + case renderIntent + case sourceToolCallID + case timestamp + } + struct ToolCall: Codable, Equatable { let id: String - let command: String + let toolName: String + let argumentsJSON: String + let command: String? let workingDirectory: String? let purpose: String? + + private enum CodingKeys: String, CodingKey { + case id + case toolName + case argumentsJSON + case command + case workingDirectory + case purpose + } + + init( + id: String, + toolName: String, + argumentsJSON: String, + command: String? = nil, + workingDirectory: String? = nil, + purpose: String? = nil + ) { + self.id = id + self.toolName = toolName + self.argumentsJSON = argumentsJSON + self.command = command + self.workingDirectory = workingDirectory + self.purpose = purpose + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(String.self, forKey: .id) + let decodedCommand = try container.decodeIfPresent(String.self, forKey: .command) + self.command = decodedCommand + self.workingDirectory = try container.decodeIfPresent( + String.self, forKey: .workingDirectory) + self.purpose = try container.decodeIfPresent(String.self, forKey: .purpose) + + if let name = try container.decodeIfPresent(String.self, forKey: .toolName), + !name.isEmpty + { + self.toolName = name + } else if decodedCommand?.isEmpty == false { + self.toolName = "execute_terminal_command" + } else { + self.toolName = "unknown_tool" + } + + if let argsJSON = try container.decodeIfPresent(String.self, forKey: .argumentsJSON), + !argsJSON.isEmpty + { + self.argumentsJSON = argsJSON + } else { + var args: [String: Any] = [:] + if let command = decodedCommand { + args["command"] = command + } + if let workingDirectory = self.workingDirectory, !workingDirectory.isEmpty { + args["workingDirectory"] = workingDirectory + } + if let purpose = self.purpose, !purpose.isEmpty { + args["purpose"] = purpose + } + + if let data = try? JSONSerialization.data( + withJSONObject: args, options: [.sortedKeys]), + let jsonString = String(data: data, encoding: .utf8) + { + self.argumentsJSON = jsonString + } else { + self.argumentsJSON = "{}" + } + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.toolName, forKey: .toolName) + try container.encode(self.argumentsJSON, forKey: .argumentsJSON) + try container.encodeIfPresent(self.command, forKey: .command) + try container.encodeIfPresent(self.workingDirectory, forKey: .workingDirectory) + try container.encodeIfPresent(self.purpose, forKey: .purpose) + } } - init(id: UUID = UUID(), role: Role, content: String, toolCall: ToolCall? = nil, stepType: StepType = .normal, timestamp: Date = Date()) { + init( + id: UUID = UUID(), + role: Role, + content: String, + toolCall: ToolCall? = nil, + stepType: StepType = .normal, + renderIntent: RenderIntent? = nil, + sourceToolCallID: String? = nil, + timestamp: Date = Date() + ) { self.id = id self.role = role self.content = content self.toolCall = toolCall self.stepType = stepType + self.renderIntent = renderIntent ?? Self.defaultRenderIntent(for: role, toolCall: toolCall) + self.sourceToolCallID = sourceToolCallID self.timestamp = timestamp } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.role = try container.decode(Role.self, forKey: .role) + self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? "" + self.toolCall = try container.decodeIfPresent(ToolCall.self, forKey: .toolCall) + self.stepType = try container.decodeIfPresent(StepType.self, forKey: .stepType) ?? .normal + self.sourceToolCallID = try container.decodeIfPresent( + String.self, forKey: .sourceToolCallID) + self.timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? Date() + + if let decodedRenderIntent = try container.decodeIfPresent( + RenderIntent.self, forKey: .renderIntent) + { + self.renderIntent = decodedRenderIntent + } else { + self.renderIntent = Self.defaultRenderIntent(for: self.role, toolCall: self.toolCall) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.role, forKey: .role) + try container.encode(self.content, forKey: .content) + try container.encodeIfPresent(self.toolCall, forKey: .toolCall) + try container.encode(self.stepType, forKey: .stepType) + try container.encode(self.renderIntent, forKey: .renderIntent) + try container.encodeIfPresent(self.sourceToolCallID, forKey: .sourceToolCallID) + try container.encode(self.timestamp, forKey: .timestamp) + } + + private static func defaultRenderIntent(for role: Role, toolCall: ToolCall?) -> RenderIntent { + switch role { + case .user: + return .userText + case .assistant: + return toolCall == nil ? .assistantText : .toolInvocation + case .tool: + return .toolResult + } + } } // MARK: - Chat Session Model @@ -60,7 +220,10 @@ struct ChatSession: Codable, Identifiable, Equatable { var updatedAt: Date var messages: [ChatMessage] - init(id: String = UUID().uuidString, title: String = "New Chat", createdAt: Date = Date(), updatedAt: Date = Date(), messages: [ChatMessage] = []) { + init( + id: String = UUID().uuidString, title: String = "New Chat", createdAt: Date = Date(), + updatedAt: Date = Date(), messages: [ChatMessage] = [] + ) { self.id = id self.title = title self.createdAt = createdAt @@ -108,7 +271,9 @@ final class ChatHistoryStore: ObservableObject { self.loadSessions() // Ensure there's always a current chat - if self.currentChatID == nil || self.sessions.first(where: { $0.id == currentChatID }) == nil { + if self.currentChatID == nil + || self.sessions.first(where: { $0.id == currentChatID }) == nil + { if let first = sessions.first { self.currentChatID = first.id } else { @@ -171,7 +336,8 @@ final class ChatHistoryStore: ObservableObject { /// Update current chat with messages func updateCurrentChat(messages: [ChatMessage]) { guard let id = currentChatID, - let index = sessions.firstIndex(where: { $0.id == id }) else { return } + let index = sessions.firstIndex(where: { $0.id == id }) + else { return } var session = self.sessions[index] session.messages = messages @@ -222,7 +388,8 @@ final class ChatHistoryStore: ObservableObject { /// Clear current chat (delete messages but keep session) func clearCurrentChat() { guard let id = currentChatID, - let index = sessions.firstIndex(where: { $0.id == id }) else { return } + let index = sessions.firstIndex(where: { $0.id == id }) + else { return } self.sessions[index].messages = [] self.sessions[index].title = "New Chat" @@ -235,7 +402,7 @@ final class ChatHistoryStore: ObservableObject { private func loadSessions() { guard let data = defaults.data(forKey: Keys.chatSessions), - let decoded = try? JSONDecoder().decode([ChatSession].self, from: data) + let decoded = try? JSONDecoder().decode([ChatSession].self, from: data) else { self.sessions = [] return diff --git a/Sources/Fluid/Resources/mcp.settings.default.json b/Sources/Fluid/Resources/mcp.settings.default.json new file mode 100644 index 00000000..e38a11b7 --- /dev/null +++ b/Sources/Fluid/Resources/mcp.settings.default.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "altic-mcp": { + "enabled": false, + "command": "uv", + "args": [ + "run", + "--project", + "/FULL/PATH/TO/altic-mcp", + "/FULL/PATH/TO/altic-mcp/server.py" + ], + "env": {} + } + } +} diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index c72470e4..d5f9f459 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -7,15 +7,24 @@ final class CommandModeService: ObservableObject { @Published var isProcessing = false @Published var pendingCommand: PendingCommand? = nil @Published var currentStep: AgentStep? = nil - @Published var streamingText: String = "" // Real-time streaming text for UI - @Published var streamingThinkingText: String = "" // Real-time thinking tokens for UI + @Published var streamingText: String = "" // Real-time streaming text for UI + @Published var streamingThinkingText: String = "" // Real-time thinking tokens for UI @Published private(set) var currentChatID: String? + @Published private(set) var mcpEnabledServerCount: Int = 0 + @Published private(set) var mcpConnectedServerCount: Int = 0 + @Published private(set) var mcpLastError: String? + @Published private(set) var isMCPBootstrapInProgress: Bool = false private let terminalService = TerminalService() + private let mcpManager = MCPManager.shared private let chatStore = ChatHistoryStore.shared private var currentTurnCount = 0 private let maxTurns = 20 private var didRequireConfirmationThisRun: Bool = false + private var isMCPSessionInitialized: Bool = false + private var cachedMCPTools: [[String: Any]] = [] + private var mcpBootstrapTask: Task? + private let mcpBootstrapWaitTimeoutNs: UInt64 = 200_000_000 // Flag to enable notch output display var enableNotchOutput: Bool = true @@ -23,14 +32,17 @@ final class CommandModeService: ObservableObject { // Streaming UI update throttling - adaptive rate based on content length private var lastUIUpdate: CFAbsoluteTime = 0 private var lastThinkingUIUpdate: CFAbsoluteTime = 0 - private var streamingBuffer: [String] = [] // Buffer tokens instead of string concat - private var thinkingBuffer: [String] = [] // Buffer thinking tokens + private var lastNotchStreamingUIUpdate: CFAbsoluteTime = 0 + private let notchStreamingUpdateInterval: CFAbsoluteTime = 0.05 + private var streamingBuffer: [String] = [] // Buffer tokens instead of string concat + private var thinkingBuffer: [String] = [] // Buffer thinking tokens // MARK: - Initialization init() { // Load current chat from store self.loadCurrentChatFromStore() + self.startMCPSessionBootstrapIfNeeded() } private var shouldSyncCommandNotchState: Bool { @@ -66,9 +78,11 @@ final class CommandModeService: ObservableObject { let id = UUID() let role: Role let content: String - let thinking: String? // Display-only: AI reasoning tokens (NOT sent to API) + let thinking: String? // Display-only: AI reasoning tokens (NOT sent to API) let toolCall: ToolCall? let stepType: StepType + let renderIntent: RenderIntent + let sourceToolCallID: String? let timestamp: Date enum Role: Equatable { @@ -77,38 +91,124 @@ final class CommandModeService: ObservableObject { case tool } + enum RenderIntent: String, Equatable { + case userText + case assistantText + case toolInvocation + case toolResult + case status + } + enum StepType: Equatable { case normal - case thinking // AI reasoning - case checking // Pre-flight verification - case executing // Running command - case verifying // Post-action check - case success // Action completed - case failure // Action failed + case thinking // AI reasoning + case checking // Pre-flight verification + case executing // Running command + case verifying // Post-action check + case success // Action completed + case failure // Action failed } struct ToolCall: Equatable { let id: String - let command: String + let toolName: String + let argumentsJSON: String + let command: String? let workingDirectory: String? - let purpose: String? // Why this command is being run + let purpose: String? // Why this command is being run + + var isTerminalCommand: Bool { + self.toolName == "execute_terminal_command" + } } - init(role: Role, content: String, thinking: String? = nil, toolCall: ToolCall? = nil, stepType: StepType = .normal) { + init( + role: Role, + content: String, + thinking: String? = nil, + toolCall: ToolCall? = nil, + stepType: StepType = .normal, + renderIntent: RenderIntent? = nil, + sourceToolCallID: String? = nil + ) { self.role = role self.content = content self.thinking = thinking self.toolCall = toolCall self.stepType = stepType + self.renderIntent = + renderIntent ?? Self.defaultRenderIntent(for: role, toolCall: toolCall) + self.sourceToolCallID = sourceToolCallID self.timestamp = Date() } + + private static func defaultRenderIntent(for role: Role, toolCall: ToolCall?) -> RenderIntent + { + switch role { + case .user: + return .userText + case .tool: + return .toolResult + case .assistant: + return toolCall == nil ? .assistantText : .toolInvocation + } + } } struct PendingCommand { + enum ToolKind { + case terminal + case mcp + } + + let kind: ToolKind let id: String - let command: String + let toolName: String + let arguments: [String: Any] + let argumentsJSON: String + let command: String? let workingDirectory: String? let purpose: String? + + var isTerminalCommand: Bool { + self.kind == .terminal + } + + static func terminal( + id: String, + command: String, + workingDirectory: String?, + purpose: String? + ) -> PendingCommand { + PendingCommand( + kind: .terminal, + id: id, + toolName: "execute_terminal_command", + arguments: [:], + argumentsJSON: "{}", + command: command, + workingDirectory: workingDirectory, + purpose: purpose + ) + } + + static func mcp( + id: String, + toolName: String, + arguments: [String: Any], + argumentsJSON: String + ) -> PendingCommand { + PendingCommand( + kind: .mcp, + id: id, + toolName: toolName, + arguments: arguments, + argumentsJSON: argumentsJSON, + command: nil, + workingDirectory: nil, + purpose: nil + ) + } } // MARK: - Public Methods @@ -125,6 +225,121 @@ final class CommandModeService: ObservableObject { NotchContentState.shared.clearCommandOutput() } + func refreshMCPStatus() async { + self.startMCPSessionBootstrapIfNeeded() + _ = await self.waitForMCPBootstrapIfNeeded( + timeoutNanoseconds: self.mcpBootstrapWaitTimeoutNs) + if self.isMCPSessionInitialized { + await self.updateMCPStatusAndToolCache() + } + } + + func reloadMCPConfiguration() async { + self.mcpBootstrapTask?.cancel() + self.mcpBootstrapTask = nil + await self.runMCPBootstrap(forceReload: true) + } + + func mcpSettingsFileURL() async -> URL? { + await self.mcpManager.settingsFileURL() + } + + func loadMCPSettingsJSON() async throws -> String { + try await self.mcpManager.loadSettingsJSON() + } + + func validateMCPSettingsJSON(_ json: String) async throws { + try await self.mcpManager.validateSettingsJSON(json) + } + + func saveMCPSettingsJSONAndReload(_ json: String) async throws { + try await self.mcpManager.saveSettingsJSON(json) + await self.reloadMCPConfiguration() + } + + private func startMCPSessionBootstrapIfNeeded() { + if self.isMCPSessionInitialized || self.mcpBootstrapTask != nil { + return + } + + DebugLogger.shared.debug("Starting background MCP bootstrap", source: "CommandModeService") + self.isMCPBootstrapInProgress = true + self.mcpBootstrapTask = Task { @MainActor [weak self] in + guard let self = self else { return } + await self.runMCPBootstrap(forceReload: false) + self.mcpBootstrapTask = nil + } + } + + private func runMCPBootstrap(forceReload: Bool) async { + self.isMCPBootstrapInProgress = true + self.isMCPSessionInitialized = false + + if Task.isCancelled { + DebugLogger.shared.debug( + "MCP bootstrap cancelled before reload", source: "CommandModeService") + self.isMCPBootstrapInProgress = false + return + } + + await self.mcpManager.reloadConfiguration(force: forceReload) + + if Task.isCancelled { + DebugLogger.shared.debug( + "MCP bootstrap cancelled after reload", source: "CommandModeService") + self.isMCPBootstrapInProgress = false + return + } + + self.isMCPSessionInitialized = true + await self.updateMCPStatusAndToolCache() + DebugLogger.shared.info( + "MCP bootstrap completed (enabled=\(self.mcpEnabledServerCount), connected=\(self.mcpConnectedServerCount), tools=\(self.cachedMCPTools.count), forced=\(forceReload))", + source: "CommandModeService") + self.isMCPBootstrapInProgress = false + } + + private func waitForMCPBootstrapIfNeeded(timeoutNanoseconds: UInt64) async -> Bool { + guard let bootstrapTask = self.mcpBootstrapTask else { + return self.isMCPSessionInitialized + } + + let finishedWithinTimeout = await withTaskGroup(of: Bool.self) { group in + group.addTask { + await bootstrapTask.value + return true + } + + group.addTask { + do { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + return false + } catch { + return true + } + } + + let result = await group.next() ?? false + group.cancelAll() + return result + } + + if finishedWithinTimeout { + self.mcpBootstrapTask = nil + } + + return self.isMCPSessionInitialized + } + + private func updateMCPStatusAndToolCache() async { + self.cachedMCPTools = await self.mcpManager.toolDefinitions(reloadIfNeeded: false) + + let summary = await self.mcpManager.statusSummary(reloadIfNeeded: false) + self.mcpEnabledServerCount = summary.enabledServers + self.mcpConnectedServerCount = summary.connectedServers + self.mcpLastError = summary.lastError + } + // MARK: - Chat Management /// Get recent chats for dropdown @@ -212,6 +427,9 @@ final class CommandModeService: ObservableObject { case .tool: role = .tool } + let renderIntent = + ChatMessage.RenderIntent(rawValue: msg.renderIntent.rawValue) ?? .assistantText + let stepType: ChatMessage.StepType switch msg.stepType { case .normal: stepType = .normal @@ -227,6 +445,8 @@ final class CommandModeService: ObservableObject { if let tc = msg.toolCall { toolCall = ChatMessage.ToolCall( id: tc.id, + toolName: tc.toolName, + argumentsJSON: tc.argumentsJSON, command: tc.command, workingDirectory: tc.workingDirectory, purpose: tc.purpose @@ -239,6 +459,8 @@ final class CommandModeService: ObservableObject { content: msg.content, toolCall: toolCall, stepType: stepType, + renderIntent: renderIntent, + sourceToolCallID: msg.sourceToolCallID, timestamp: msg.timestamp ) } @@ -251,6 +473,9 @@ final class CommandModeService: ObservableObject { case .tool: role = .tool } + let renderIntent = + Message.RenderIntent(rawValue: chatMsg.renderIntent.rawValue) ?? .assistantText + let stepType: Message.StepType switch chatMsg.stepType { case .normal: stepType = .normal @@ -266,6 +491,8 @@ final class CommandModeService: ObservableObject { if let tc = chatMsg.toolCall { toolCall = Message.ToolCall( id: tc.id, + toolName: tc.toolName, + argumentsJSON: tc.argumentsJSON, command: tc.command, workingDirectory: tc.workingDirectory, purpose: tc.purpose @@ -276,7 +503,9 @@ final class CommandModeService: ObservableObject { role: role, content: chatMsg.content, toolCall: toolCall, - stepType: stepType + stepType: stepType, + renderIntent: renderIntent, + sourceToolCallID: chatMsg.sourceToolCallID ) } @@ -289,18 +518,62 @@ final class CommandModeService: ObservableObject { NotchContentState.shared.clearCommandOutput() for msg in self.conversationHistory { - let role: NotchContentState.CommandOutputMessage.Role - switch msg.role { - case .user: role = .user - case .assistant: role = .assistant - case .tool: role = .status // Tool outputs shown as status in notch + switch msg.renderIntent { + case .userText: + guard !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + NotchContentState.shared.addCommandMessage(role: .user, content: msg.content) + case .assistantText: + guard !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + NotchContentState.shared.addCommandMessage(role: .assistant, content: msg.content) + case .status: + guard !msg.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + NotchContentState.shared.addCommandMessage(role: .status, content: msg.content) + case .toolInvocation: + let statusText = self.notchStatusText(for: msg) + guard !statusText.isEmpty else { continue } + NotchContentState.shared.addCommandMessage(role: .status, content: statusText) + case .toolResult: + continue } + } + } + + private func notchStatusText(for message: Message) -> String { + if let purpose = message.toolCall?.purpose?.trimmingCharacters(in: .whitespacesAndNewlines), + !purpose.isEmpty + { + return purpose + } + + if let tc = message.toolCall { + if tc.isTerminalCommand { + if let command = tc.command?.trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty + { + return "Running: \(self.truncateStatusText(command, limit: 80))" + } - // Skip tool outputs in notch (they're verbose) - if msg.role == .tool { continue } + let defaultStatus = self.stepDescription(for: message.stepType) + return defaultStatus.isEmpty ? "Running command..." : defaultStatus + } - NotchContentState.shared.addCommandMessage(role: role, content: msg.content) + return "Calling MCP tool: \(tc.toolName)" } + + return message.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func truncateStatusText(_ text: String, limit: Int) -> String { + if text.count <= limit { + return text + } + return String(text.prefix(limit - 1)) + "..." } /// Process user voice/text command @@ -317,6 +590,7 @@ final class CommandModeService: ObservableObject { // Push to notch if self.shouldSyncCommandNotchState { + NotchContentState.shared.clearCommandTurnBadge() NotchContentState.shared.addCommandMessage(role: .user, content: text) NotchContentState.shared.setCommandProcessing(true) } @@ -340,6 +614,7 @@ final class CommandModeService: ObservableObject { self.isProcessing = true self.didRequireConfirmationThisRun = false if self.shouldSyncCommandNotchState { + NotchContentState.shared.clearCommandTurnBadge() NotchContentState.shared.setCommandProcessing(true) } @@ -352,17 +627,38 @@ final class CommandModeService: ObservableObject { self.pendingCommand = nil self.isProcessing = true - await self.executeCommand(pending.command, workingDirectory: pending.workingDirectory, callId: pending.id) + switch pending.kind { + case .terminal: + guard let command = pending.command else { return } + await self.executeCommand( + command, + workingDirectory: pending.workingDirectory, + callId: pending.id, + purpose: pending.purpose + ) + case .mcp: + await self.executeMCPTool( + name: pending.toolName, + arguments: pending.arguments, + callId: pending.id + ) + } } /// Cancel pending command func cancelPendingCommand() { + let cancellationText = + self.pendingCommand?.isTerminalCommand == true + ? "Command cancelled." + : "Tool call cancelled." self.pendingCommand = nil - self.conversationHistory.append(Message( - role: .assistant, - content: "Command cancelled.", - stepType: .failure - )) + self.conversationHistory.append( + Message( + role: .assistant, + content: cancellationText, + stepType: .failure, + renderIntent: .status + )) self.isProcessing = false self.currentStep = nil } @@ -371,12 +667,14 @@ final class CommandModeService: ObservableObject { private func processNextTurn() async { if self.currentTurnCount >= self.maxTurns { - let errorMsg = "Reached maximum steps limit. Please review the progress and continue if needed." - self.conversationHistory.append(Message( - role: .assistant, - content: errorMsg, - stepType: .failure - )) + let errorMsg = + "Reached maximum steps limit. Please review the progress and continue if needed." + self.conversationHistory.append( + Message( + role: .assistant, + content: errorMsg, + stepType: .failure + )) self.isProcessing = false self.currentStep = .completed(false) @@ -389,7 +687,7 @@ final class CommandModeService: ObservableObject { if self.shouldSyncCommandNotchState { NotchContentState.shared.addCommandMessage(role: .assistant, content: errorMsg) NotchContentState.shared.setCommandProcessing(false) - self.showExpandedNotchIfNeeded() + self.showCompletionBadgeIfNeeded(success: false) } return } @@ -405,68 +703,125 @@ final class CommandModeService: ObservableObject { do { let response = try await callLLM() - if let tc = response.toolCall { - // Determine step type based on command purpose - let stepType = self.determineStepType(for: tc.command, purpose: tc.purpose) - self.currentStep = stepType == .checking ? .checking(tc.command) : .executing(tc.command) + switch response.turnKind { + case .toolCallOnly, .toolCallWithText: + let tc = response.toolCalls[0] + let argsJSON = self.encodeToolArgumentsJSON(tc.arguments) + let isTerminalTool = tc.name == "execute_terminal_command" + let command = tc.getString("command") ?? "" + let workDir = tc.getOptionalString("workingDirectory") + let purpose = tc.getString("purpose") + + // Determine step type based on tool kind + let stepType: Message.StepType = + isTerminalTool + ? self.determineStepType(for: command, purpose: purpose) + : .executing + + let stepLabel = isTerminalTool ? command : tc.name + switch stepType { + case .checking: + self.currentStep = .checking(stepLabel) + case .verifying: + self.currentStep = .verifying(stepLabel) + default: + self.currentStep = .executing(stepLabel) + } - // AI wants to run a command - include thinking for display - self.conversationHistory.append(Message( + let toolMessage = Message( role: .assistant, - content: response.content.isEmpty ? self.stepDescription(for: stepType) : response.content, - thinking: response.thinking, // Display-only + content: response.normalizedContent, + thinking: response.thinking, toolCall: Message.ToolCall( id: tc.id, - command: tc.command, - workingDirectory: tc - .workingDirectory, - purpose: tc.purpose + toolName: tc.name, + argumentsJSON: argsJSON, + command: isTerminalTool ? command : nil, + workingDirectory: workDir, + purpose: purpose ), - stepType: stepType - )) + stepType: stepType, + renderIntent: .toolInvocation + ) + self.conversationHistory.append(toolMessage) // Push step to notch if self.shouldSyncCommandNotchState { - let statusText = tc.purpose ?? self.stepDescription(for: stepType) - NotchContentState.shared.addCommandMessage(role: .status, content: statusText) + let statusText = self.notchStatusText(for: toolMessage) + if !statusText.isEmpty { + NotchContentState.shared.addCommandMessage( + role: .status, content: statusText) + } } - // Check if we need confirmation for destructive commands - if SettingsStore.shared.commandModeConfirmBeforeExecute, self.isDestructiveCommand(tc.command) { - self.didRequireConfirmationThisRun = true - self.pendingCommand = PendingCommand( - id: tc.id, - command: tc.command, - workingDirectory: tc.workingDirectory, - purpose: tc.purpose - ) - self.isProcessing = false - self.currentStep = nil - - // Push confirmation needed to notch - if self.shouldSyncCommandNotchState { - NotchContentState.shared.addCommandMessage(role: .status, content: "⚠️ Confirmation needed in Command Mode window") - NotchContentState.shared.setCommandProcessing(false) + if isTerminalTool { + // Check if we need confirmation for destructive commands + if SettingsStore.shared.commandModeConfirmBeforeExecute, + self.isDestructiveCommand(command) + { + self.didRequireConfirmationThisRun = true + self.pendingCommand = .terminal( + id: tc.id, + command: command, + workingDirectory: workDir, + purpose: purpose + ) + self.isProcessing = false + self.currentStep = nil + + // Push confirmation needed to notch + if self.shouldSyncCommandNotchState { + NotchContentState.shared.addCommandMessage( + role: .status, + content: "⚠️ Confirmation needed in Command Mode window") + NotchContentState.shared.setCommandProcessing(false) + } + return } - return - } - // Auto-execute - await self.executeCommand(tc.command, workingDirectory: tc.workingDirectory, callId: tc.id, purpose: tc.purpose) + // Auto-execute terminal tool + await self.executeCommand( + command, workingDirectory: workDir, callId: tc.id, purpose: purpose) + } else { + if SettingsStore.shared.commandModeConfirmBeforeExecute { + self.didRequireConfirmationThisRun = true + self.pendingCommand = .mcp( + id: tc.id, + toolName: tc.name, + arguments: tc.arguments, + argumentsJSON: argsJSON + ) + self.isProcessing = false + self.currentStep = nil - } else { - // Just a text response - check if it's a final summary - let isFinal = response.content.lowercased().contains("complete") || - response.content.lowercased().contains("done") || - response.content.lowercased().contains("success") || - response.content.lowercased().contains("finished") + if self.shouldSyncCommandNotchState { + NotchContentState.shared.addCommandMessage( + role: .status, + content: "⚠️ Confirmation needed in Command Mode window") + NotchContentState.shared.setCommandProcessing(false) + } + return + } - self.conversationHistory.append(Message( - role: .assistant, - content: response.content, - thinking: response.thinking, // Display-only - stepType: isFinal ? .success : .normal - )) + await self.executeMCPTool(name: tc.name, arguments: tc.arguments, callId: tc.id) + } + + case .textOnly, .empty: + let finalContent = + response.normalizedContent.isEmpty + ? "I couldn't understand that." : response.normalizedContent + + // Just a text response - infer whether this is a successful completion summary. + let isFinal = self.isSuccessfulCompletionSummary(finalContent) + + self.conversationHistory.append( + Message( + role: .assistant, + content: finalContent, + thinking: response.thinking, + stepType: isFinal ? .success : .normal, + renderIntent: .assistantText + )) self.isProcessing = false self.currentStep = .completed(isFinal) @@ -475,22 +830,24 @@ final class CommandModeService: ObservableObject { self.captureCommandRunCompleted(success: isFinal) - // Push final response to notch and show expanded view + // Push final response to notch and show compact completion badge if self.shouldSyncCommandNotchState { - NotchContentState.shared.updateCommandStreamingText("") // Clear streaming - NotchContentState.shared.addCommandMessage(role: .assistant, content: response.content) + NotchContentState.shared.updateCommandStreamingText("") // Clear streaming + NotchContentState.shared.addCommandMessage( + role: .assistant, content: finalContent) NotchContentState.shared.setCommandProcessing(false) - self.showExpandedNotchIfNeeded() + self.showCompletionBadgeIfNeeded(success: isFinal) } } } catch { let errorMsg = "Error: \(error.localizedDescription)" - self.conversationHistory.append(Message( - role: .assistant, - content: errorMsg, - stepType: .failure - )) + self.conversationHistory.append( + Message( + role: .assistant, + content: errorMsg, + stepType: .failure + )) self.isProcessing = false self.currentStep = .completed(false) @@ -503,7 +860,7 @@ final class CommandModeService: ObservableObject { if self.shouldSyncCommandNotchState { NotchContentState.shared.addCommandMessage(role: .assistant, content: errorMsg) NotchContentState.shared.setCommandProcessing(false) - self.showExpandedNotchIfNeeded() + self.showCompletionBadgeIfNeeded(success: false) } } } @@ -540,14 +897,63 @@ final class CommandModeService: ObservableObject { ) } - /// Show expanded notch output if there's content to display - private func showExpandedNotchIfNeeded() { + /// Show compact completion badge in the notch if there's content to display + private func showCompletionBadgeIfNeeded(success: Bool) { + guard success else { return } guard self.shouldSyncCommandNotchState else { return } - guard NotchOverlayManager.shared.canShowExpandedCommandOutput else { return } guard !NotchContentState.shared.commandConversationHistory.isEmpty else { return } - // Show the expanded notch - NotchOverlayManager.shared.showExpandedCommandOutput() + NotchOverlayManager.shared.showCommandCompletionBadge(success: success) + } + + private func isSuccessfulCompletionSummary(_ summary: String) -> Bool { + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + + if trimmed.contains("✗") { + return false + } + if trimmed.contains("✓") || trimmed.contains("✔") { + return true + } + + let lowered = trimmed.lowercased() + let failureKeywords = [ + "failed", "failure", "error", "unable", "cannot", "can't", "could not", "couldn't", + "not possible", "did not", "didn't", + ] + if failureKeywords.contains(where: { lowered.contains($0) }) { + return false + } + + let successKeywords = [ + "complete", "completed", "done", "success", "successful", "finished", + ] + if successKeywords.contains(where: { lowered.contains($0) }) { + return true + } + + // If the model gave a neutral summary, fall back to tool outcomes for this run. + let toolResults = self.currentRunToolResultMessages() + if toolResults.contains(where: { $0.stepType == .failure }) { + return false + } + if toolResults.contains(where: { $0.stepType == .success }) { + return true + } + + return false + } + + private func currentRunToolResultMessages() -> [Message] { + guard let lastUserIndex = self.conversationHistory.lastIndex(where: { $0.role == .user }) + else { + return [] + } + + let messagesAfterUser = self.conversationHistory.suffix( + from: self.conversationHistory.index(after: lastUserIndex)) + return messagesAfterUser.filter { $0.role == .tool } } private func determineStepType(for command: String, purpose: String?) -> Message.StepType { @@ -555,12 +961,15 @@ final class CommandModeService: ObservableObject { let purposeLower = purpose?.lowercased() ?? "" // Check commands - if purposeLower.contains("check") || purposeLower.contains("verify") || purposeLower.contains("exist") { + if purposeLower.contains("check") || purposeLower.contains("verify") + || purposeLower.contains("exist") + { return .checking } - if cmd.hasPrefix("ls ") || cmd.hasPrefix("cat ") || cmd.hasPrefix("test ") || cmd.hasPrefix("[ ") || - cmd.contains("--version") || cmd.contains("which ") || cmd.contains("file ") || - cmd.hasPrefix("stat ") || cmd.hasPrefix("head ") || cmd.hasPrefix("tail ") + if cmd.hasPrefix("ls ") || cmd.hasPrefix("cat ") || cmd.hasPrefix("test ") + || cmd.hasPrefix("[ ") || cmd.contains("--version") || cmd.contains("which ") + || cmd.contains("file ") || cmd.hasPrefix("stat ") || cmd.hasPrefix("head ") + || cmd.hasPrefix("tail ") { return .checking } @@ -587,16 +996,16 @@ final class CommandModeService: ObservableObject { // Commands that start with these are destructive let destructivePrefixes = [ - "rm ", "rm\t", "rmdir ", "rm -", // delete - "mv ", "mv\t", // move/rename - "sudo ", // elevated privileges - "kill ", "pkill ", "killall ", // terminate processes - "chmod ", "chown ", "chgrp ", // change permissions/ownership - "dd ", // disk operations - "mkfs", "format", // filesystem formatting - "> ", // overwrite file - "truncate ", // truncate file - "shred ", // secure delete + "rm ", "rm\t", "rmdir ", "rm -", // delete + "mv ", "mv\t", // move/rename + "sudo ", // elevated privileges + "kill ", "pkill ", "killall ", // terminate processes + "chmod ", "chown ", "chgrp ", // change permissions/ownership + "dd ", // disk operations + "mkfs", "format", // filesystem formatting + "> ", // overwrite file + "truncate ", // truncate file + "shred ", // secure delete ] // Check if command starts with any destructive prefix @@ -624,7 +1033,20 @@ final class CommandModeService: ObservableObject { return false } - private func executeCommand(_ command: String, workingDirectory: String?, callId: String, purpose: String? = nil) async { + private func encodeToolArgumentsJSON(_ arguments: [String: Any]) -> String { + guard JSONSerialization.isValidJSONObject(arguments), + let data = try? JSONSerialization.data( + withJSONObject: arguments, options: [.sortedKeys]), + let jsonString = String(data: data, encoding: .utf8) + else { + return "{}" + } + return jsonString + } + + private func executeCommand( + _ command: String, workingDirectory: String?, callId: String, purpose: String? = nil + ) async { self.currentStep = .executing(command) let result = await terminalService.execute( @@ -644,11 +1066,51 @@ final class CommandModeService: ObservableObject { let resultStepType: Message.StepType = result.success ? .success : .failure // Add tool result to conversation - self.conversationHistory.append(Message( - role: .tool, - content: resultJSON, - stepType: resultStepType - )) + self.conversationHistory.append( + Message( + role: .tool, + content: resultJSON, + stepType: resultStepType, + renderIntent: .toolResult, + sourceToolCallID: callId + )) + + // Continue the loop - let the AI see the result and decide what to do next + await self.processNextTurn() + } + + private func executeMCPTool(name: String, arguments: [String: Any], callId: String) async { + self.currentStep = .executing(name) + + let startTime = Date() + let result = await self.mcpManager.callTool( + functionName: name, arguments: arguments, reloadIfNeeded: false) + let executionTimeMs = Int(Date().timeIntervalSince(startTime) * 1000) + + let enhancedResult = EnhancedMCPToolResult( + success: result.success, + toolName: result.toolName, + serverID: result.serverID, + output: result.output, + error: result.error, + isError: result.isError, + content: result.content, + executionTimeMs: executionTimeMs + ) + + let resultJSON = enhancedResult.toJSON() + let resultStepType: Message.StepType = result.success ? .success : .failure + + self.conversationHistory.append( + Message( + role: .tool, + content: resultJSON, + stepType: resultStepType, + renderIntent: .toolResult, + sourceToolCallID: callId + )) + + await self.updateMCPStatusAndToolCache() // Continue the loop - let the AI see the result and decide what to do next await self.processNextTurn() @@ -679,13 +1141,35 @@ final class CommandModeService: ObservableObject { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] if let data = try? encoder.encode(self), - let json = String(data: data, encoding: .utf8) + let json = String(data: data, encoding: .utf8) { return json } return """ - {"success": \(self.success), "output": "\(self.output)", "exitCode": \(self.exitCode)} - """ + {"success": \(self.success), "output": "\(self.output)", "exitCode": \(self.exitCode)} + """ + } + } + + private struct EnhancedMCPToolResult: Codable { + let success: Bool + let toolName: String + let serverID: String? + let output: String + let error: String? + let isError: Bool + let content: [MCPManager.ToolExecutionContent] + let executionTimeMs: Int + + func toJSON() -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(self), + let json = String(data: data, encoding: .utf8) + { + return json + } + return "{\"success\":false,\"output\":\"\",\"exitCode\":-1}" } } @@ -693,14 +1177,46 @@ final class CommandModeService: ObservableObject { private struct LLMResponse { let content: String - let thinking: String? // Display-only, NOT sent back to API - let toolCall: ToolCallData? + let thinking: String? // Display-only, NOT sent back to API + let toolCalls: [ToolCallData] + + enum TurnKind { + case toolCallOnly + case toolCallWithText + case textOnly + case empty + } + + var normalizedContent: String { + self.content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var turnKind: TurnKind { + let hasToolCalls = !self.toolCalls.isEmpty + let hasText = !self.normalizedContent.isEmpty + + switch (hasToolCalls, hasText) { + case (true, true): return .toolCallWithText + case (true, false): return .toolCallOnly + case (false, true): return .textOnly + case (false, false): return .empty + } + } struct ToolCallData { let id: String - let command: String - let workingDirectory: String? - let purpose: String? + let name: String + let arguments: [String: Any] + + func getString(_ key: String) -> String? { + self.arguments[key] as? String + } + + func getOptionalString(_ key: String) -> String? { + guard let value = self.arguments[key] as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } } } @@ -722,88 +1238,115 @@ final class CommandModeService: ObservableObject { // Build conversation with agentic system prompt let systemPrompt = """ - You are an autonomous, thoughtful macOS terminal agent. Execute user requests reliably and safely. - - ## AGENTIC WORKFLOW (Follow this pattern): - - ### 1. PRE-FLIGHT CHECKS (Always do this first!) - Before ANY action, verify prerequisites: - - File operations: Check if file/folder exists first (`ls`, `test -e`, `[ -f file ]`) - - Deletions: List contents before removing, confirm target exists - - Modifications: Read current state before changing - - Installations: Check if already installed (`which`, `--version`) - - ### 2. EXECUTE WITH CONTEXT - When calling execute_terminal_command, ALWAYS include a `purpose` parameter explaining: - - "checking" - Verifying something exists/state - - "executing" - Performing the main action - - "verifying" - Confirming the result - Example purposes: "Checking if image1.png exists", "Creating the backup directory", "Verifying file was deleted" - - ### 3. POST-ACTION VERIFICATION - After modifying anything, verify it worked: - - Created file? `ls` to confirm it exists - - Deleted file? `ls` to confirm it's gone - - Modified content? `cat` or `head` to verify changes - - Installed app? Check version/existence - - ### 4. HANDLE FAILURES GRACEFULLY - - If something doesn't exist: Tell the user clearly - - If command fails: Analyze error, try alternative approach - - If permission denied: Explain and suggest solutions - - Never assume success without verification - - ## RESPONSE FORMAT: - - Keep reasoning brief and clear - - State what you're checking/doing before each command - - After verification, give a clear success/failure summary - - Use natural language, not code comments - - ## SAFETY RULES: - - For destructive ops (rm, mv, overwrite): ALWAYS check target exists first - - Show what will be affected before destroying - - Prefer `rm -i` or listing contents before bulk deletes - - Use full absolute paths when possible - - ## EXAMPLES OF GOOD BEHAVIOR: - - User: "Delete image1.png in Downloads" - You: First check if it exists - → execute_terminal_command(command: "ls -la ~/Downloads/image1.png", purpose: "Checking if image1.png exists") - If exists → execute_terminal_command(command: "rm ~/Downloads/image1.png", purpose: "Deleting the file") - Then verify → execute_terminal_command(command: "ls ~/Downloads/image1.png 2>&1", purpose: "Verifying file was deleted") - Finally: "✓ Successfully deleted image1.png from Downloads." - - User: "Create a project folder with a readme" - You: → Check if folder exists, create it, create readme, verify both - - ## NATIVE macOS APP CONTROL (Use osascript): - For Reminders, Notes, Calendar, Messages, Mail, and other native macOS apps, use `osascript`: - - ### Reminders: - - Create reminder (default list): `osascript -e 'tell application "Reminders" to make new reminder with properties {name:""}'` - - Create in specific list: `osascript -e 'tell application "Reminders" to make new reminder at end of list "" with properties {name:""}'` - - With due date: `osascript -e 'tell application "Reminders" to make new reminder with properties {name:"", due date:date "12/25/2024 3:00 PM"}'` - - ⚠️ Do NOT use `reminders list 1` syntax - it causes errors. Use `list ""` or omit the list entirely. - - ### Notes: - - Create note: `osascript -e 'tell application "Notes" to make new note at folder "Notes" with properties {name:"", body:"<content>"}'` - - ### Calendar: - - Create event: `osascript -e 'tell application "Calendar" to tell calendar "<CalendarName>" to make new event with properties {summary:"<title>", start date:date "<date>", end date:date "<date>"}'` - - ### Messages: - - Send iMessage: `osascript -e 'tell application "Messages" to send "<message>" to buddy "<phone/email>"'` - - ### General Pattern: - Always use `osascript -e 'tell application "<AppName>" to ...'` for native app automation. - - The user is on macOS with zsh shell. Be thorough but efficient. - When task is complete, provide a clear summary starting with ✓ or ✗. - """ + You are an autonomous, thoughtful macOS terminal agent. Execute user requests reliably and safely. + You may also be given MCP tools in addition to terminal access. Use MCP tools when they are a better fit than shell commands. + If you use terminal commands, follow the pre-flight/execute/verify workflow strictly. + + ## AGENTIC WORKFLOW (Follow this pattern): + + ### 1. PRE-FLIGHT CHECKS (Always do this first!) + Before ANY action, verify prerequisites: + - File operations: Check if file/folder exists first (`ls`, `test -e`, `[ -f file ]`) + - Deletions: List contents before removing, confirm target exists + - Modifications: Read current state before changing + - Installations: Check if already installed (`which`, `--version`) + + ### 2. EXECUTE WITH CONTEXT + When calling execute_terminal_command, ALWAYS include a `purpose` parameter explaining: + - "checking" - Verifying something exists/state + - "executing" - Performing the main action + - "verifying" - Confirming the result + Example purposes: "Checking if image1.png exists", "Creating the backup directory", "Verifying file was deleted" + + ### 3. POST-ACTION VERIFICATION + After modifying anything, verify it worked: + - Created file? `ls` to confirm it exists + - Deleted file? `ls` to confirm it's gone + - Modified content? `cat` or `head` to verify changes + - Installed app? Check version/existence + + ### 4. HANDLE FAILURES GRACEFULLY + - If something doesn't exist: Tell the user clearly + - If command fails: Analyze error, try alternative approach + - If permission denied: Explain and suggest solutions + - Never assume success without verification + + ## INTENT NORMALIZATION (CRITICAL FOR USER-FACING CONTENT): + Before executing any action, rewrite the user's request into a clean action payload. + Separate: + - Instruction wrapper (what to do, who to send to, where to create) + - User-facing payload (the actual message/body/title/content) + + Never include instruction phrasing in sent/saved content. + For "send/tell/message/email X saying ...", only the text after "saying/that/with message" is the message body. + + Examples: + - User: "Send a message to Alex saying we can grab dinner" + -> recipient: Alex + -> message body: "we can grab dinner" + -> DO NOT send: "Send a message to Alex saying we can grab dinner" + + - User: "Create a reminder to call mom tomorrow" + -> reminder title: "call mom" + -> due date: tomorrow + + - User: "Write a note titled Grocery List with eggs, milk, bread" + -> note title: "Grocery List" + -> note body: "eggs, milk, bread" + + If payload extraction is ambiguous, choose the most literal minimal user-intended content. + + ## RESPONSE FORMAT: + - Keep reasoning brief and clear + - State what you're checking/doing before each command + - After verification, give a clear success/failure summary + - Use natural language, not code comments + + ## SAFETY RULES: + - For destructive ops (rm, mv, overwrite): ALWAYS check target exists first + - Show what will be affected before destroying + - Prefer `rm -i` or listing contents before bulk deletes + - Use full absolute paths when possible + + ## EXAMPLES OF GOOD BEHAVIOR: + + User: "Delete image1.png in Downloads" + You: First check if it exists + → execute_terminal_command(command: "ls -la ~/Downloads/image1.png", purpose: "Checking if image1.png exists") + If exists → execute_terminal_command(command: "rm ~/Downloads/image1.png", purpose: "Deleting the file") + Then verify → execute_terminal_command(command: "ls ~/Downloads/image1.png 2>&1", purpose: "Verifying file was deleted") + Finally: "✓ Successfully deleted image1.png from Downloads." + + User: "Create a project folder with a readme" + You: → Check if folder exists, create it, create readme, verify both + + ## NATIVE macOS APP CONTROL (Use osascript if there are no MCPs configured): + For Reminders, Notes, Calendar, Messages, Mail, and other native macOS apps, use `osascript`: + + ### Reminders: + - Create reminder (default list): `osascript -e 'tell application "Reminders" to make new reminder with properties {name:"<text>"}'` + - Create in specific list: `osascript -e 'tell application "Reminders" to make new reminder at end of list "<ListName>" with properties {name:"<text>"}'` + - With due date: `osascript -e 'tell application "Reminders" to make new reminder with properties {name:"<text>", due date:date "12/25/2024 3:00 PM"}'` + - ⚠️ Do NOT use `reminders list 1` syntax - it causes errors. Use `list "<name>"` or omit the list entirely. + + ### Notes: + - Create note: `osascript -e 'tell application "Notes" to make new note at folder "Notes" with properties {name:"<title>", body:"<content>"}'` + + ### Calendar: + - Create event: `osascript -e 'tell application "Calendar" to tell calendar "<CalendarName>" to make new event with properties {summary:"<title>", start date:date "<date>", end date:date "<date>"}'` + + ### Messages: + - Send iMessage: `osascript -e 'tell application "Messages" to send "<message>" to buddy "<phone/email>"'` + + ### General Pattern: + Always use `osascript -e 'tell application "<AppName>" to ...'` for native app automation. + + The user is on macOS with zsh shell. Be thorough but efficient. + When task is complete, provide a clear summary starting with ✓ or ✗. + """ var messages: [[String: Any]] = [ - ["role": "system", "content": systemPrompt], + ["role": "system", "content": systemPrompt] ] // Add conversation history @@ -816,28 +1359,19 @@ final class CommandModeService: ObservableObject { case .assistant: if let tc = msg.toolCall { lastToolCallId = tc.id - let argsJSON: String - do { - let data = try JSONSerialization.data(withJSONObject: [ - "command": tc.command, - "workingDirectory": tc.workingDirectory ?? "", - ]) - argsJSON = String(data: data, encoding: .utf8) ?? "{}" - } catch { - DebugLogger.shared.error("Failed to encode tool call args: \(error)", source: "CommandModeService") - argsJSON = "{}" - } messages.append([ "role": "assistant", "content": msg.content, - "tool_calls": [[ - "id": tc.id, - "type": "function", - "function": [ - "name": "execute_terminal_command", - "arguments": argsJSON, - ], - ]], + "tool_calls": [ + [ + "id": tc.id, + "type": "function", + "function": [ + "name": tc.toolName, + "arguments": tc.argumentsJSON.isEmpty ? "{}" : tc.argumentsJSON, + ], + ] + ], ]) } else { messages.append(["role": "assistant", "content": msg.content]) @@ -859,7 +1393,8 @@ final class CommandModeService: ObservableObject { let isTemperatureUnsupported = settings.isTemperatureUnsupported(model) // Get reasoning config for this model (e.g., reasoning_effort, enable_thinking) - let reasoningConfig = SettingsStore.shared.getReasoningConfig(forModel: model, provider: providerID) + let reasoningConfig = SettingsStore.shared.getReasoningConfig( + forModel: model, provider: providerID) var extraParams: [String: Any] = [:] if let rConfig = reasoningConfig, rConfig.isEnabled { if rConfig.parameterName == "enable_thinking" { @@ -867,7 +1402,9 @@ final class CommandModeService: ObservableObject { } else { extraParams = [rConfig.parameterName: rConfig.parameterValue] } - DebugLogger.shared.debug("Added reasoning param: \(rConfig.parameterName)=\(rConfig.parameterValue)", source: "CommandModeService") + DebugLogger.shared.debug( + "Added reasoning param: \(rConfig.parameterName)=\(rConfig.parameterValue)", + source: "CommandModeService") } // Reset streaming state @@ -877,17 +1414,30 @@ final class CommandModeService: ObservableObject { self.thinkingBuffer = [] self.lastUIUpdate = CFAbsoluteTimeGetCurrent() self.lastThinkingUIUpdate = CFAbsoluteTimeGetCurrent() + self.lastNotchStreamingUIUpdate = CFAbsoluteTimeGetCurrent() + + // MCP bootstrap runs in the background. If it's not ready quickly, proceed with terminal-only tools. + self.startMCPSessionBootstrapIfNeeded() + let mcpReadyForThisTurn = await self.waitForMCPBootstrapIfNeeded( + timeoutNanoseconds: self.mcpBootstrapWaitTimeoutNs) + if !mcpReadyForThisTurn, self.cachedMCPTools.isEmpty { + DebugLogger.shared.info( + "MCP bootstrap still in progress; continuing this turn with terminal-only tools", + source: "CommandModeService") + } + let allTools = [TerminalService.toolDefinition] + self.cachedMCPTools // Build LLMClient configuration var config = LLMClient.Config( messages: messages, + providerID: providerID, model: model, baseURL: baseURL, apiKey: apiKey, streaming: enableStreaming, - tools: [TerminalService.toolDefinition], - temperature: isTemperatureUnsupported ? nil : 0.1, - maxTokens: isReasoningModel ? 32_000 : nil, // Reasoning models like o1 need a large budget for extended thought chains + tools: allTools, + temperature: (isReasoningModel || isTemperatureUnsupported) ? nil : 0.1, + maxTokens: isReasoningModel ? 32_000 : nil, // Reasoning models like o1 need a large budget for extended thought chains extraParameters: extraParams ) @@ -926,7 +1476,11 @@ final class CommandModeService: ObservableObject { self.streamingText = fullContent // Push to notch for real-time display - if self.shouldSyncCommandNotchState { + if self.shouldSyncCommandNotchState, + now - self.lastNotchStreamingUIUpdate + >= self.notchStreamingUpdateInterval + { + self.lastNotchStreamingUIUpdate = now NotchContentState.shared.updateCommandStreamingText(fullContent) } } @@ -934,7 +1488,9 @@ final class CommandModeService: ObservableObject { } } - DebugLogger.shared.info("Using LLMClient for Command Mode (streaming=\(enableStreaming), messages=\(messages.count), history=\(self.conversationHistory.count))", source: "CommandModeService") + DebugLogger.shared.info( + "Using LLMClient for Command Mode (streaming=\(enableStreaming), messages=\(messages.count), history=\(self.conversationHistory.count), tools=\(allTools.count), mcpTools=\(self.cachedMCPTools.count), mcpReady=\(mcpReadyForThisTurn))", + source: "CommandModeService") let response = try await LLMClient.shared.call(config) @@ -948,15 +1504,16 @@ final class CommandModeService: ObservableObject { } // Small delay to let the final content render, then clear - try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms // Capture final thinking before clearing (for message storage) - let finalThinking = response.thinking ?? (self.thinkingBuffer.isEmpty ? nil : self.thinkingBuffer.joined()) + let finalThinking = + response.thinking ?? (self.thinkingBuffer.isEmpty ? nil : self.thinkingBuffer.joined()) - self.streamingText = "" // Clear streaming text when done - self.streamingThinkingText = "" // Clear thinking text when done - self.streamingBuffer = [] // Clear buffer - self.thinkingBuffer = [] // Clear thinking buffer + self.streamingText = "" // Clear streaming text when done + self.streamingThinkingText = "" // Clear thinking text when done + self.streamingBuffer = [] // Clear buffer + self.thinkingBuffer = [] // Clear thinking buffer // Clear notch streaming text as well if self.shouldSyncCommandNotchState { @@ -965,35 +1522,24 @@ final class CommandModeService: ObservableObject { // Log thinking if present (for debugging) if let thinking = finalThinking { - DebugLogger.shared.debug("LLM thinking tokens extracted (\(thinking.count) chars)", source: "CommandModeService") + DebugLogger.shared.debug( + "LLM thinking tokens extracted (\(thinking.count) chars)", + source: "CommandModeService") } // Convert LLMClient.Response to our internal LLMResponse - // Check for tool calls - if let tc = response.toolCalls.first, - tc.name == "execute_terminal_command" - { - let command = tc.getString("command") ?? "" - let workDir = tc.getOptionalString("workingDirectory") - let purpose = tc.getString("purpose") - - return LLMResponse( - content: response.content, - thinking: finalThinking, // Display-only - toolCall: LLMResponse.ToolCallData( - id: tc.id, - command: command, - workingDirectory: workDir, - purpose: purpose - ) + let mappedToolCalls = response.toolCalls.map { toolCall in + LLMResponse.ToolCallData( + id: toolCall.id, + name: toolCall.name, + arguments: toolCall.arguments ) } - // Text response only return LLMResponse( - content: response.content.isEmpty ? "I couldn't understand that." : response.content, - thinking: finalThinking, // Display-only - toolCall: nil + content: response.content, + thinking: finalThinking, // Display-only + toolCalls: mappedToolCalls ) } } diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index 065cdb52..7e400043 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -42,6 +42,12 @@ final class LLMClient { /// URLSession configured with appropriate timeouts private let session: URLSession + private enum APIFormat { + case chatCompletions + case responses + case anthropicMessages + } + private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = Self.defaultTimeoutSeconds @@ -49,6 +55,10 @@ final class LLMClient { self.session = URLSession(configuration: config) } + init(session: URLSession) { + self.session = session + } + // MARK: - Response Types struct Response { @@ -81,6 +91,7 @@ final class LLMClient { struct Config { let messages: [[String: Any]] + let providerID: String? let model: String let baseURL: String let apiKey: String @@ -88,7 +99,7 @@ final class LLMClient { let tools: [[String: Any]] let temperature: Double? - /// Optional token limit (max_tokens or max_completion_tokens depending on model) + /// Optional token limit (max_tokens/max_completion_tokens for chat, max_output_tokens for responses) var maxTokens: Int? /// Extra parameters to add to the request body (e.g., reasoning_effort, enable_thinking) @@ -111,6 +122,7 @@ final class LLMClient { init( messages: [[String: Any]], + providerID: String? = nil, model: String, baseURL: String, apiKey: String, @@ -121,6 +133,7 @@ final class LLMClient { extraParameters: [String: Any] = [:] ) { self.messages = messages + self.providerID = providerID self.model = model self.baseURL = baseURL self.apiKey = apiKey @@ -132,37 +145,189 @@ final class LLMClient { } } + // MARK: - Routing Abstractions + + private enum ProviderFamily { + case anthropic + case openAICompatible + } + + private struct RoutePlan { + let primaryFormat: APIFormat + let fallbackFormat: APIFormat? + } + + private struct PreparedRequest { + var request: URLRequest + let format: APIFormat + } + + private protocol APIRouteStrategy { + var format: APIFormat { get } + func endpoint(for baseURL: String) -> String + func applyHeaders(apiKey: String, request: inout URLRequest) + } + + private struct AnthropicMessagesRouteStrategy: APIRouteStrategy { + let format: APIFormat = .anthropicMessages + + func endpoint(for baseURL: String) -> String { + if baseURL.contains("/messages") { + return baseURL + } + + let base = baseURL.isEmpty ? ModelRepository.shared.defaultBaseURL(for: "anthropic") : baseURL + return "\(base)/messages" + } + + func applyHeaders(apiKey: String, request: inout URLRequest) { + if !apiKey.isEmpty { + request.addValue(apiKey, forHTTPHeaderField: "x-api-key") + } + request.addValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + } + } + + private struct ResponsesRouteStrategy: APIRouteStrategy { + let format: APIFormat = .responses + + func endpoint(for baseURL: String) -> String { + if baseURL.contains("/responses") { + return baseURL + } + + if baseURL.contains("/chat/completions") { + return baseURL.replacingOccurrences(of: "/chat/completions", with: "/responses") + } + + if baseURL.contains("/api/chat") || baseURL.contains("/api/generate") { + // Some OpenAI-compatible providers only expose chat-style paths. + // We still attempt Responses schema first and rely on fallback to chat completions. + return baseURL + } + + let base = baseURL.isEmpty ? ModelRepository.shared.defaultBaseURL(for: "openai") : baseURL + return "\(base)/responses" + } + + func applyHeaders(apiKey: String, request: inout URLRequest) { + if !apiKey.isEmpty { + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + } + } + + private struct ChatCompletionsRouteStrategy: APIRouteStrategy { + let format: APIFormat = .chatCompletions + + func endpoint(for baseURL: String) -> String { + if baseURL.contains("/chat/completions") || baseURL.contains("/api/chat") || baseURL.contains("/api/generate") { + return baseURL + } + + if baseURL.contains("/responses") { + return baseURL.replacingOccurrences(of: "/responses", with: "/chat/completions") + } + + let base = baseURL.isEmpty ? ModelRepository.shared.defaultBaseURL(for: "openai") : baseURL + return "\(base)/chat/completions" + } + + func applyHeaders(apiKey: String, request: inout URLRequest) { + if !apiKey.isEmpty { + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + } + } + + private func providerFamily(for config: Config) -> ProviderFamily { + if let providerID = config.providerID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + providerID == "anthropic" + { + return .anthropic + } + + let baseURL = config.baseURL.lowercased() + if baseURL.contains("anthropic.com") { + return .anthropic + } + + return .openAICompatible + } + + private func routePlan(for config: Config) -> RoutePlan { + switch self.providerFamily(for: config) { + case .anthropic: + return RoutePlan(primaryFormat: .anthropicMessages, fallbackFormat: nil) + case .openAICompatible: + return RoutePlan(primaryFormat: .responses, fallbackFormat: .chatCompletions) + } + } + + private func routeStrategy(for format: APIFormat) -> any APIRouteStrategy { + switch format { + case .anthropicMessages: + return AnthropicMessagesRouteStrategy() + case .responses: + return ResponsesRouteStrategy() + case .chatCompletions: + return ChatCompletionsRouteStrategy() + } + } + // MARK: - Main Entry Point /// Make an LLM API call with the given configuration. /// Supports both streaming and non-streaming modes. /// Handles thinking token extraction, tool call parsing, and retries. func call(_ config: Config) async throws -> Response { - var request = try buildRequest(config) + let routePlan = self.routePlan(for: config) + var preparedRequest = try self.buildRequest(config, forcedFormat: routePlan.primaryFormat) // Apply timeout to the request itself let timeout = config.timeoutSeconds ?? Self.defaultTimeoutSeconds - request.timeoutInterval = timeout + preparedRequest.request.timeoutInterval = timeout - self.logRequest(request) + self.logRequest(preparedRequest.request) // Execute the request. We rely on URLRequest/URLSession timeouts (30s default) rather // than racing a separate "timeout task". A task-group timeout wrapper can accidentally // keep the caller suspended until the full timeout elapses, which is the exact stall // we want to eliminate for overlay responsiveness. - return try await self.executeWithRetry(request: request, config: config) + return try await self.executeWithRetry( + request: preparedRequest, + config: config, + routePlan: routePlan + ) } /// Execute request with retry logic (extracted for timeout wrapper) - private func executeWithRetry(request: URLRequest, config: Config) async throws -> Response { + private func executeWithRetry(request: PreparedRequest, config: Config, routePlan: RoutePlan) async throws -> Response { + var currentRequest = request + var attemptedResponsesFallback = false var lastError: Error? + for attempt in 1...config.maxRetries { do { if config.streaming { - return try await self.processStreaming(request: request, config: config) + return try await self.processStreaming(request: currentRequest.request, format: currentRequest.format, config: config) } else { - return try await self.processNonStreaming(request: request) + return try await self.processNonStreaming(request: currentRequest.request, format: currentRequest.format) } + } catch LLMError.httpError(let code, let message) + where !attemptedResponsesFallback && + currentRequest.format == .responses && + routePlan.fallbackFormat != nil && + self.shouldFallbackToChat(statusCode: code, message: message) + { + attemptedResponsesFallback = true + DebugLogger.shared.warning( + "LLMClient: Responses endpoint rejected request (HTTP \(code)); falling back to chat completions", + source: "LLMClient" + ) + guard let fallbackFormat = routePlan.fallbackFormat else { continue } + currentRequest = try self.buildRequest(config, forcedFormat: fallbackFormat) + continue } catch let error as URLError where self.isRetryableError(error) { lastError = error DebugLogger.shared.warning("LLMClient: Retry \(attempt)/\(config.maxRetries) due to \(error.code.rawValue)", source: "LLMClient") @@ -184,64 +349,80 @@ final class LLMClient { // MARK: - Request Building - private func buildRequest(_ config: Config) throws -> URLRequest { - // Build endpoint URL + private func buildRequest(_ config: Config, forcedFormat: APIFormat? = nil) throws -> PreparedRequest { let baseURL = config.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let endpoint: String - if baseURL.contains("/chat/completions") || - baseURL.contains("/api/chat") || - baseURL.contains("/api/generate") - { - endpoint = baseURL - } else { - endpoint = baseURL.isEmpty ? "\(ModelRepository.shared.defaultBaseURL(for: "openai"))/chat/completions" : "\(baseURL)/chat/completions" - } + let plan = self.routePlan(for: config) + let apiFormat = forcedFormat ?? plan.primaryFormat + let strategy = self.routeStrategy(for: apiFormat) + let endpoint = strategy.endpoint(for: baseURL) guard let url = URL(string: endpoint) else { throw LLMError.invalidURL } - // Detect if this is a local endpoint (skip auth for local) - let isLocal = self.isLocalEndpoint(baseURL) - // Build request body + let body: [String: Any] + switch apiFormat { + case .chatCompletions: + body = self.buildChatCompletionsBody(config) + case .responses: + body = self.buildResponsesBody(config) + case .anthropicMessages: + body = self.buildAnthropicMessagesBody(config) + } + + // Serialize to JSON + guard let jsonData = try? JSONSerialization.data(withJSONObject: body, options: []) else { + throw LLMError.encodingError + } + + // Log the request for debugging + let messageCount = config.messages.count + if let bodyStr = String(data: jsonData, encoding: .utf8) { + let truncated = bodyStr.count > 500 ? String(bodyStr.prefix(500)) + "..." : bodyStr + DebugLogger.shared.debug("LLMClient: Request (\(messageCount) messages, model=\(config.model), streaming=\(config.streaming)): \(truncated)", source: "LLMClient") + } + + // Build URLRequest + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + strategy.applyHeaders(apiKey: config.apiKey, request: &request) + + request.httpBody = jsonData + + return PreparedRequest(request: request, format: apiFormat) + } + + private func buildChatCompletionsBody(_ config: Config) -> [String: Any] { var body: [String: Any] = [ "model": config.model, "messages": config.messages, ] - // Add temperature if provided (reasoning models like o1/o3/gpt-5 don't support it) if let temp = config.temperature { body["temperature"] = temp } - // Add tools if provided if !config.tools.isEmpty { - body["tools"] = config.tools + body["tools"] = self.normalizeToolsForChatCompletions(config.tools) body["tool_choice"] = "auto" } - // Add streaming flag if config.streaming { body["stream"] = true } - // Add extra parameters in layers: - // 1. Model-specific parameters (from ThinkingParserFactory) - // 2. User-provided parameters (can override model defaults) - - // Layer 1: Model-specific parameters (e.g., enable_thinking for Nemotron) let modelExtras = ThinkingParserFactory.getExtraParameters(for: config.model) for (key, value) in modelExtras { body[key] = value } - // Layer 2: User-provided extra parameters (e.g., reasoning_effort from settings) for (key, value) in config.extraParameters { body[key] = value } - // Final Layer: Common parameters with model-specific keys if let tokens = config.maxTokens { if SettingsStore.shared.isReasoningModel(config.model) { body["max_completion_tokens"] = tokens @@ -250,36 +431,413 @@ final class LLMClient { } } - // Serialize to JSON - guard let jsonData = try? JSONSerialization.data(withJSONObject: body, options: []) else { - throw LLMError.encodingError + return body + } + + private func buildResponsesBody(_ config: Config) -> [String: Any] { + let (instructions, input) = self.convertMessagesToResponsesInput(config.messages) + + var body: [String: Any] = [ + "model": config.model, + "input": input, + ] + + if let instructions { + body["instructions"] = instructions } - // Log the request for debugging - let messageCount = config.messages.count - if let bodyStr = String(data: jsonData, encoding: .utf8) { - let truncated = bodyStr.count > 500 ? String(bodyStr.prefix(500)) + "..." : bodyStr - DebugLogger.shared.debug("LLMClient: Request (\(messageCount) messages, model=\(config.model), streaming=\(config.streaming)): \(truncated)", source: "LLMClient") + if let temp = config.temperature { + body["temperature"] = temp } - // Build URLRequest - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") + if !config.tools.isEmpty { + body["tools"] = self.normalizeToolsForResponses(config.tools) + body["tool_choice"] = "auto" + } - // Send Authorization whenever a key exists; some localhost endpoints still require auth. - if !config.apiKey.isEmpty { - request.addValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + if config.streaming { + body["stream"] = true } - request.httpBody = jsonData + let modelExtras = ThinkingParserFactory.getExtraParameters(for: config.model) + for (key, value) in modelExtras { + self.applyResponsesExtraParameter(key: key, value: value, body: &body) + } + + for (key, value) in config.extraParameters { + self.applyResponsesExtraParameter(key: key, value: value, body: &body) + } + + if let tokens = config.maxTokens { + body["max_output_tokens"] = tokens + } + + return body + } + + private func buildAnthropicMessagesBody(_ config: Config) -> [String: Any] { + let (system, messages) = self.convertMessagesToAnthropicInput(config.messages) + + var body: [String: Any] = [ + "model": config.model, + "messages": messages, + "max_tokens": max(1, config.maxTokens ?? 2048), + ] + + if let system, !system.isEmpty { + body["system"] = system + } + + if let temperature = config.temperature { + body["temperature"] = temperature + } + + if !config.tools.isEmpty { + body["tools"] = self.normalizeToolsForAnthropic(config.tools) + body["tool_choice"] = ["type": "auto"] + } + + if config.streaming { + body["stream"] = true + } + + let modelExtras = ThinkingParserFactory.getExtraParameters(for: config.model) + for (key, value) in modelExtras { + self.applyAnthropicExtraParameter(key: key, value: value, body: &body) + } + + for (key, value) in config.extraParameters { + self.applyAnthropicExtraParameter(key: key, value: value, body: &body) + } + + return body + } + + private func convertMessagesToAnthropicInput(_ messages: [[String: Any]]) -> (String?, [[String: Any]]) { + var systemParts: [String] = [] + var outputMessages: [[String: Any]] = [] + + for message in messages { + let role = (message["role"] as? String ?? "user").lowercased() + + switch role { + case "system": + let text = self.extractStringContent(from: message) + if !text.isEmpty { + systemParts.append(text) + } + + case "tool": + let toolUseID = message["tool_call_id"] as? String ?? "tool_\(UUID().uuidString.prefix(8))" + var toolResult: [String: Any] = [ + "type": "tool_result", + "tool_use_id": toolUseID, + ] + + if let contentBlocks = message["content"] as? [[String: Any]], !contentBlocks.isEmpty { + toolResult["content"] = contentBlocks + } else { + let text = self.extractStringContent(from: message) + if !text.isEmpty { + toolResult["content"] = text + } + } - return request + if let isError = message["is_error"] as? Bool { + toolResult["is_error"] = isError + } + + outputMessages.append([ + "role": "user", + "content": [toolResult], + ]) + + case "assistant": + let text = self.extractStringContent(from: message) + let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + var contentBlocks: [[String: Any]] = [] + + if !trimmedText.isEmpty { + contentBlocks.append([ + "type": "text", + "text": trimmedText, + ]) + } + + if let toolCalls = message["tool_calls"] as? [[String: Any]], !toolCalls.isEmpty { + for toolCall in toolCalls { + let function = toolCall["function"] as? [String: Any] + let name = function?["name"] as? String ?? toolCall["name"] as? String + guard let toolName = name else { continue } + + let callID = toolCall["id"] as? String ?? "tool_\(UUID().uuidString.prefix(8))" + let argumentsString = function?["arguments"] as? String ?? toolCall["arguments"] as? String ?? "{}" + + let input: [String: Any] + if let data = argumentsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + input = parsed + } else { + input = [:] + } + + contentBlocks.append([ + "type": "tool_use", + "id": callID, + "name": toolName, + "input": input, + ]) + } + } + + if contentBlocks.isEmpty, + let blocks = message["content"] as? [[String: Any]], + !blocks.isEmpty + { + outputMessages.append([ + "role": "assistant", + "content": blocks, + ]) + } else if contentBlocks.isEmpty { + continue + } else { + outputMessages.append([ + "role": "assistant", + "content": contentBlocks, + ]) + } + + default: + if let blocks = message["content"] as? [[String: Any]], !blocks.isEmpty { + outputMessages.append([ + "role": "user", + "content": blocks, + ]) + } else { + outputMessages.append([ + "role": "user", + "content": self.extractStringContent(from: message), + ]) + } + } + } + + let mergedSystem = systemParts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + + return (mergedSystem.isEmpty ? nil : mergedSystem, outputMessages) + } + + private func convertMessagesToResponsesInput(_ messages: [[String: Any]]) -> (String?, [[String: Any]]) { + var instructions: [String] = [] + var input: [[String: Any]] = [] + + for message in messages { + let role = message["role"] as? String ?? "user" + let content = message["content"] as? String ?? "" + + if role == "system" { + if !content.isEmpty { + instructions.append(content) + } + continue + } + + if role == "tool" { + let callId = message["tool_call_id"] as? String ?? "call_unknown" + input.append([ + "type": "function_call_output", + "call_id": callId, + "output": content, + ]) + continue + } + + if role == "assistant", + let toolCalls = message["tool_calls"] as? [[String: Any]], + !toolCalls.isEmpty + { + for toolCall in toolCalls { + guard let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String + else { + continue + } + + input.append([ + "type": "function_call", + "call_id": toolCall["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))", + "name": name, + "arguments": function["arguments"] as? String ?? "{}", + ]) + } + + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + input.append([ + "role": "assistant", + "content": trimmed, + ]) + } + continue + } + + input.append([ + "role": role, + "content": content, + ]) + } + + let mergedInstructions = instructions.joined(separator: "\n\n").trimmingCharacters(in: .whitespacesAndNewlines) + return (mergedInstructions.isEmpty ? nil : mergedInstructions, input) + } + + private func normalizeToolsForResponses(_ tools: [[String: Any]]) -> [[String: Any]] { + return tools.map { tool in + guard (tool["type"] as? String) == "function", + let function = tool["function"] as? [String: Any], + let name = function["name"] as? String + else { + return tool + } + + var normalized: [String: Any] = [ + "type": "function", + "name": name, + ] + + if let description = function["description"] { + normalized["description"] = description + } + if let parameters = function["parameters"] { + normalized["parameters"] = parameters + } + if let strict = function["strict"] ?? tool["strict"] { + normalized["strict"] = strict + } + + return normalized + } + } + + private func normalizeToolsForChatCompletions(_ tools: [[String: Any]]) -> [[String: Any]] { + return tools.map { tool in + guard (tool["type"] as? String) == "function", + tool["function"] == nil, + let name = tool["name"] as? String + else { + return tool + } + + var function: [String: Any] = [ + "name": name, + ] + + if let description = tool["description"] { + function["description"] = description + } + if let parameters = tool["parameters"] { + function["parameters"] = parameters + } + if let strict = tool["strict"] { + function["strict"] = strict + } + + return [ + "type": "function", + "function": function, + ] + } + } + + private func normalizeToolsForAnthropic(_ tools: [[String: Any]]) -> [[String: Any]] { + return tools.compactMap { tool in + let function = tool["function"] as? [String: Any] + let name = function?["name"] as? String ?? tool["name"] as? String + guard let toolName = name, !toolName.isEmpty else { return nil } + + let description = function?["description"] as? String ?? tool["description"] as? String + var inputSchema = function?["parameters"] as? [String: Any] ?? + tool["parameters"] as? [String: Any] ?? + tool["input_schema"] as? [String: Any] ?? [:] + + if inputSchema["type"] == nil { + inputSchema["type"] = "object" + } + if inputSchema["properties"] == nil { + inputSchema["properties"] = [:] + } + + var normalized: [String: Any] = [ + "name": toolName, + "input_schema": inputSchema, + ] + + if let description, !description.isEmpty { + normalized["description"] = description + } + + if let strict = function?["strict"] ?? tool["strict"] { + normalized["strict"] = strict + } + + if let inputExamples = function?["input_examples"] ?? tool["input_examples"] { + normalized["input_examples"] = inputExamples + } + + if let cacheControl = tool["cache_control"] { + normalized["cache_control"] = cacheControl + } + + return normalized + } + } + + private func applyResponsesExtraParameter(key: String, value: Any, body: inout [String: Any]) { + switch key { + case "reasoning_effort": + var reasoning = body["reasoning"] as? [String: Any] ?? [:] + reasoning["effort"] = value + body["reasoning"] = reasoning + default: + body[key] = value + } + } + + private func applyAnthropicExtraParameter(key: String, value: Any, body: inout [String: Any]) { + switch key { + case "reasoning_effort", "enable_thinking", "max_output_tokens", "max_completion_tokens": + // OpenAI-compatible parameters are ignored on Anthropic payloads. + return + default: + body[key] = value + } + } + + private func extractStringContent(from message: [String: Any]) -> String { + if let text = message["content"] as? String { + return text + } + + guard let blocks = message["content"] as? [[String: Any]] else { + return "" + } + + return blocks.compactMap { block in + let type = (block["type"] as? String ?? "").lowercased() + if type == "text" { + return block["text"] as? String + } + return nil + }.joined() } // MARK: - Non-Streaming Response - private func processNonStreaming(request: URLRequest) async throws -> Response { + private func processNonStreaming(request: URLRequest, format: APIFormat) async throws -> Response { DebugLogger.shared.debug("LLMClient: Making non-streaming request to \(request.url?.absoluteString ?? "unknown")", source: "LLMClient") let (data, response) = try await self.session.data(for: request) @@ -292,8 +850,19 @@ final class LLMClient { DebugLogger.shared.debug("LLMClient: Non-streaming response received (\(data.count) bytes)", source: "LLMClient") - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw LLMError.invalidResponse + } + + if format == .anthropicMessages { + return self.parseAnthropicResponse(json) + } + + if format == .responses { + return self.parseResponsesResponse(json) + } + + guard let choices = json["choices"] as? [[String: Any]], let choice = choices.first, let message = choice["message"] as? [String: Any] else { @@ -303,7 +872,7 @@ final class LLMClient { return self.parseMessageResponse(message) } - private func processStreaming(request: URLRequest, config: Config) async throws -> Response { + private func processStreaming(request: URLRequest, format: APIFormat, config: Config) async throws -> Response { DebugLogger.shared.debug("LLMClient: Starting streaming request to \(request.url?.absoluteString ?? "unknown")", source: "LLMClient") let (bytes, response) = try await self.session.bytes(for: request) @@ -318,8 +887,9 @@ final class LLMClient { throw LLMError.httpError(http.statusCode, errText) } - // Create the appropriate parser for this model - var parser = ThinkingParserFactory.createParser(for: config.model) + // Create the appropriate parser for this model. + // Anthropic streams thinking and text in separate content blocks, so we always use separate-field parsing there. + var parser: ThinkingParser = ThinkingParserFactory.createParser(for: config.model) // Streaming state var state = ThinkingParserState.initial @@ -327,10 +897,67 @@ final class LLMClient { var contentBuffer: [String] = [] var tagDetectionBuffer = "" - // Tool call accumulation - var toolCallId: String? - var toolCallName: String? - var toolCallArguments = "" + let isResponses = format == .responses + let isAnthropic = format == .anthropicMessages + + if isAnthropic { + parser = SeparateFieldThinkingParser() + } + + // Tool call accumulation (supports multiple tool calls in one streamed response) + struct StreamingToolAccumulator { + var id: String? + var name: String? + var arguments: String = "" + } + var toolCallAccumulators: [String: StreamingToolAccumulator] = [:] + var sawOutputTextDelta = false + var anthropicToolIndexToID: [Int: String] = [:] + var responsesOutputIndexToAccumulatorKey: [Int: String] = [:] + + func processContentChunk(_ content: String) { + let containsThinkTag = content.contains("<think") || content.contains("</think") || content.contains("<thinking") || content.contains("</thinking") + if thinkingBuffer.count + contentBuffer.count < 8 || containsThinkTag { + let escaped = content.replacingOccurrences(of: "\n", with: "\\n") + let marker = containsThinkTag ? " [HAS THINK TAG!]" : "" + DebugLogger.shared.debug("LLMClient: Chunk '\(escaped)'\(marker)", source: "LLMClient") + } + + let previousState = state + let (newState, thinkChunk, contentChunk) = parser.processChunk( + content, + currentState: state, + tagBuffer: &tagDetectionBuffer + ) + + if previousState != .inThinking && newState == .inThinking { + DebugLogger.shared.debug("LLMClient: State transition → inThinking", source: "LLMClient") + config.onThinkingStart?() + } + if previousState == .inThinking && newState == .inContent { + DebugLogger.shared.debug("LLMClient: State transition → inContent", source: "LLMClient") + config.onThinkingEnd?() + } + state = newState + + if !thinkChunk.isEmpty { + thinkingBuffer.append(thinkChunk) + config.onThinkingChunk?(thinkChunk) + } + if !contentChunk.isEmpty { + contentBuffer.append(contentChunk) + config.onContentChunk?(contentChunk) + } + } + + func processReasoningChunk(_ chunk: String) { + if state == .initial { + state = .inThinking + config.onThinkingStart?() + } + thinkingBuffer.append(chunk) + config.onThinkingChunk?(chunk) + } // Process SSE lines for try await rawLine in bytes.lines { @@ -347,8 +974,198 @@ final class LLMClient { } guard let jsonData = jsonString.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], + let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + else { + continue + } + + if isAnthropic { + let eventType = json["type"] as? String ?? "" + + if eventType == "error", + let errorObject = json["error"] as? [String: Any], + let message = errorObject["message"] as? String + { + throw LLMError.httpError(500, message) + } + + switch eventType { + case "content_block_start": + guard let index = json["index"] as? Int, + let contentBlock = json["content_block"] as? [String: Any] + else { continue } + + let blockType = contentBlock["type"] as? String ?? "" + + if blockType == "tool_use" { + let id = contentBlock["id"] as? String ?? "tool_\(UUID().uuidString.prefix(8))" + let name = contentBlock["name"] as? String + var accumulator = toolCallAccumulators[id] ?? StreamingToolAccumulator() + accumulator.id = id + + if let name { + if accumulator.name == nil { + config.onToolCallStart?(name) + } + accumulator.name = name + } + + if let input = contentBlock["input"] as? [String: Any], + !input.isEmpty, + let inputData = try? JSONSerialization.data(withJSONObject: input, options: []), + let inputJSON = String(data: inputData, encoding: .utf8) + { + accumulator.arguments = inputJSON + } + + toolCallAccumulators[id] = accumulator + anthropicToolIndexToID[index] = id + } + + case "content_block_delta": + guard let delta = json["delta"] as? [String: Any] else { continue } + let deltaType = delta["type"] as? String ?? "" + + switch deltaType { + case "text_delta": + if let text = delta["text"] as? String { + processContentChunk(text) + } + + case "thinking_delta": + if let thinking = delta["thinking"] as? String { + processReasoningChunk(thinking) + } + + case "input_json_delta": + guard let index = json["index"] as? Int else { continue } + let toolID = anthropicToolIndexToID[index] ?? "index_\(index)" + var accumulator = toolCallAccumulators[toolID] ?? StreamingToolAccumulator() + accumulator.id = accumulator.id ?? toolID + + if let partialJSON = delta["partial_json"] as? String { + accumulator.arguments += partialJSON + } + + toolCallAccumulators[toolID] = accumulator + + default: + break + } + + default: + break + } + + continue + } + + if isResponses { + let eventType = json["type"] as? String ?? "" + + switch eventType { + case "response.output_text.delta": + if let delta = json["delta"] as? String { + sawOutputTextDelta = true + processContentChunk(delta) + } + + case "response.output_text.done": + if !sawOutputTextDelta, let text = json["text"] as? String { + processContentChunk(text) + } + + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + let outputIndex = json["output_index"] as? Int + let accumulatorKey: String + if let itemID = json["item_id"] as? String { + accumulatorKey = itemID + if let outputIndex { + responsesOutputIndexToAccumulatorKey[outputIndex] = itemID + } + } else if let outputIndex, + let knownKey = responsesOutputIndexToAccumulatorKey[outputIndex] + { + accumulatorKey = knownKey + } else { + accumulatorKey = "index_\(outputIndex ?? 0)" + } + + var accumulator = toolCallAccumulators[accumulatorKey] ?? StreamingToolAccumulator() + + if let delta = json["delta"] as? String { + accumulator.arguments += delta + } + if let arguments = json["arguments"] as? String { + accumulator.arguments = arguments + } + + toolCallAccumulators[accumulatorKey] = accumulator + + case "response.output_item.added", "response.output_item.done": + guard let item = json["item"] as? [String: Any] else { continue } + + if (item["type"] as? String) == "function_call" { + let outputIndex = json["output_index"] as? Int + let fallbackIndexKey = "index_\(outputIndex ?? 0)" + let accumulatorKey = + item["id"] as? String ?? json["item_id"] as? String + ?? (outputIndex.map { "index_\($0)" }) + ?? item["call_id"] as? String + ?? "call_\(UUID().uuidString.prefix(8))" + let callID = item["call_id"] as? String ?? accumulatorKey + + if let outputIndex { + responsesOutputIndexToAccumulatorKey[outputIndex] = accumulatorKey + } + + if accumulatorKey != fallbackIndexKey, + toolCallAccumulators[accumulatorKey] == nil, + let existingAccumulator = toolCallAccumulators.removeValue(forKey: fallbackIndexKey) + { + toolCallAccumulators[accumulatorKey] = existingAccumulator + } + + var accumulator = + toolCallAccumulators[accumulatorKey] ?? StreamingToolAccumulator() + accumulator.id = callID + if let name = item["name"] as? String { + if accumulator.name == nil { + config.onToolCallStart?(name) + } + accumulator.name = name + } + if let arguments = item["arguments"] as? String { + accumulator.arguments = arguments + } + toolCallAccumulators[accumulatorKey] = accumulator + } else if (item["type"] as? String) == "reasoning" { + if let summary = item["summary"] as? String, !summary.isEmpty { + processReasoningChunk(summary) + } else if let summaryItems = item["summary"] as? [[String: Any]] { + for summaryItem in summaryItems { + if let text = summaryItem["text"] as? String, !text.isEmpty { + processReasoningChunk(text) + } + } + } + } + + default: + if eventType.contains("reasoning") { + if let delta = json["delta"] as? String, !delta.isEmpty { + processReasoningChunk(delta) + } + if let text = json["text"] as? String, !text.isEmpty { + processReasoningChunk(text) + } + } + } + + continue + } + + guard let choices = json["choices"] as? [[String: Any]], let delta = choices.first?["delta"] as? [String: Any] else { continue @@ -368,12 +1185,7 @@ final class LLMClient { delta["thinking"] as? String if let reasoning = reasoningField { - if state == .initial { - state = .inThinking - config.onThinkingStart?() - } - thinkingBuffer.append(reasoning) - config.onThinkingChunk?(reasoning) + processReasoningChunk(reasoning) } // Handle content with potential <think> tags @@ -387,58 +1199,31 @@ final class LLMClient { // For safety with tag-based parsers, we let the parser decide unless it's a known separate-field model. } - // Debug: Log first few chunks and any chunk containing think tags - let containsThinkTag = content.contains("<think") || content.contains("</think") || content.contains("<thinking") || content.contains("</thinking") - if thinkingBuffer.count + contentBuffer.count < 8 || containsThinkTag { - let escaped = content.replacingOccurrences(of: "\n", with: "\\n") - let marker = containsThinkTag ? " [HAS THINK TAG!]" : "" - DebugLogger.shared.debug("LLMClient: Chunk '\(escaped)'\(marker)", source: "LLMClient") - } - - let previousState = state - let (newState, thinkChunk, contentChunk) = parser.processChunk( - content, - currentState: state, - tagBuffer: &tagDetectionBuffer - ) - - // Handle state transitions for callbacks - if previousState != .inThinking && newState == .inThinking { - DebugLogger.shared.debug("LLMClient: State transition → inThinking", source: "LLMClient") - config.onThinkingStart?() - } - if previousState == .inThinking && newState == .inContent { - DebugLogger.shared.debug("LLMClient: State transition → inContent", source: "LLMClient") - config.onThinkingEnd?() - } - state = newState - - // Accumulate and callback - if !thinkChunk.isEmpty { - thinkingBuffer.append(thinkChunk) - config.onThinkingChunk?(thinkChunk) - } - if !contentChunk.isEmpty { - contentBuffer.append(contentChunk) - config.onContentChunk?(contentChunk) - } + processContentChunk(content) } - // Handle tool calls (streamed in parts) - if let toolCalls = delta["tool_calls"] as? [[String: Any]], - let tc = toolCalls.first - { - if let id = tc["id"] as? String { - toolCallId = id - } - if let function = tc["function"] as? [String: Any] { - if let name = function["name"] as? String { - toolCallName = name - config.onToolCallStart?(name) + // Handle tool calls (streamed in parts, potentially multiple) + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for tc in toolCalls { + let index = tc["index"] as? Int ?? 0 + let key = "index_\(index)" + var accumulator = toolCallAccumulators[key] ?? StreamingToolAccumulator() + + if let id = tc["id"] as? String { + accumulator.id = id } - if let args = function["arguments"] as? String { - toolCallArguments += args + + if let function = tc["function"] as? [String: Any] { + if let name = function["name"] as? String { + accumulator.name = name + config.onToolCallStart?(name) + } + if let args = function["arguments"] as? String { + accumulator.arguments += args + } } + + toolCallAccumulators[key] = accumulator } } } @@ -464,18 +1249,35 @@ final class LLMClient { // Build tool calls array var parsedToolCalls: [ToolCall] = [] - if let name = toolCallName, - let argsData = toolCallArguments.data(using: .utf8), - let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] - { - parsedToolCalls = [ - ToolCall( - id: toolCallId ?? "call_\(UUID().uuidString.prefix(8))", - name: name, - arguments: args - ), - ] - DebugLogger.shared.debug("LLMClient: Parsed tool call: \(name)", source: "LLMClient") + if !toolCallAccumulators.isEmpty { + for key in toolCallAccumulators.keys.sorted() { + guard let accumulator = toolCallAccumulators[key], + let name = accumulator.name + else { + continue + } + + let args: [String: Any] + if accumulator.arguments.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args = [:] + } else if let argsData = accumulator.arguments.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] + { + args = parsed + } else { + DebugLogger.shared.warning("LLMClient: Failed to parse streamed tool arguments for '\(name)'", source: "LLMClient") + args = [:] + } + + parsedToolCalls.append( + ToolCall( + id: accumulator.id ?? "call_\(UUID().uuidString.prefix(8))", + name: name, + arguments: args + ) + ) + DebugLogger.shared.debug("LLMClient: Parsed tool call [\(key)]: \(name)", source: "LLMClient") + } } DebugLogger.shared.debug("LLMClient: Returning response. Content length: \(contentText.count), Has thinking: \(thinkingText.isEmpty ? "No" : "Yes (\(thinkingText.count) chars)")", source: "LLMClient") @@ -487,6 +1289,164 @@ final class LLMClient { ) } + private func parseResponsesResponse(_ json: [String: Any]) -> Response { + var contentParts: [String] = [] + var thinkingParts: [String] = [] + var toolCalls: [ToolCall] = [] + + if let output = json["output"] as? [[String: Any]] { + for item in output { + let type = item["type"] as? String ?? "" + + if type == "message" { + contentParts.append(contentsOf: self.extractTextParts(from: item)) + continue + } + + if type == "function_call", let toolCall = self.parseResponsesToolCall(item) { + toolCalls.append(toolCall) + continue + } + + if type == "reasoning" { + if let summary = item["summary"] as? String, !summary.isEmpty { + thinkingParts.append(summary) + } else if let summaryItems = item["summary"] as? [[String: Any]] { + for summaryItem in summaryItems { + if let text = summaryItem["text"] as? String, !text.isEmpty { + thinkingParts.append(text) + } + } + } + } + } + } + + if contentParts.isEmpty, let outputText = json["output_text"] as? String, !outputText.isEmpty { + contentParts.append(outputText) + } + + let rawContent = contentParts.joined().trimmingCharacters(in: .whitespacesAndNewlines) + let (tagThinking, cleanedContent) = self.stripThinkingTags(rawContent) + let allThinking = (thinkingParts + [tagThinking]).filter { !$0.isEmpty }.joined(separator: "\n") + + return Response( + thinking: allThinking.isEmpty ? nil : allThinking, + content: cleanedContent.isEmpty ? rawContent : cleanedContent, + toolCalls: toolCalls + ) + } + + private func parseAnthropicResponse(_ json: [String: Any]) -> Response { + var contentParts: [String] = [] + var thinkingParts: [String] = [] + var toolCalls: [ToolCall] = [] + + if let contentBlocks = json["content"] as? [[String: Any]] { + for block in contentBlocks { + let type = block["type"] as? String ?? "" + + switch type { + case "text": + if let text = block["text"] as? String, !text.isEmpty { + contentParts.append(text) + } + + case "thinking": + if let thinking = block["thinking"] as? String, !thinking.isEmpty { + thinkingParts.append(thinking) + } + + case "redacted_thinking": + if let redacted = block["data"] as? String, !redacted.isEmpty { + thinkingParts.append(redacted) + } + + case "tool_use": + if let toolCall = self.parseAnthropicToolUse(block) { + toolCalls.append(toolCall) + } + + default: + break + } + } + } + + let content = contentParts.joined().trimmingCharacters(in: .whitespacesAndNewlines) + let thinking = thinkingParts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + + return Response( + thinking: thinking.isEmpty ? nil : thinking, + content: content, + toolCalls: toolCalls + ) + } + + private func parseAnthropicToolUse(_ block: [String: Any]) -> ToolCall? { + guard let name = block["name"] as? String else { + return nil + } + + let id = block["id"] as? String ?? "tool_\(UUID().uuidString.prefix(8))" + + if let input = block["input"] as? [String: Any] { + return ToolCall(id: id, name: name, arguments: input) + } + + if let inputString = block["input"] as? String, + let data = inputString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + return ToolCall(id: id, name: name, arguments: parsed) + } + + return ToolCall(id: id, name: name, arguments: [:]) + } + + private func extractTextParts(from messageItem: [String: Any]) -> [String] { + if let text = messageItem["content"] as? String { + return text.isEmpty ? [] : [text] + } + + guard let contentItems = messageItem["content"] as? [[String: Any]] else { + return [] + } + + var parts: [String] = [] + for contentItem in contentItems { + if let text = contentItem["text"] as? String, !text.isEmpty { + parts.append(text) + } + } + return parts + } + + private func parseResponsesToolCall(_ item: [String: Any]) -> ToolCall? { + guard let name = item["name"] as? String else { + return nil + } + + let id = item["call_id"] as? String ?? item["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))" + let argumentsString = item["arguments"] as? String ?? "{}" + + let args: [String: Any] + if argumentsString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args = [:] + } else if let argsData = argumentsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] + { + args = parsed + } else { + args = [:] + } + + return ToolCall(id: id, name: name, arguments: args) + } + // MARK: - Parse Non-Streaming Message private func parseMessageResponse(_ message: [String: Any]) -> Response { @@ -498,13 +1458,23 @@ final class LLMClient { if let toolCalls = message["tool_calls"] as? [[String: Any]] { parsedToolCalls = toolCalls.compactMap { tc -> ToolCall? in guard let function = tc["function"] as? [String: Any], - let name = function["name"] as? String, - let argsString = function["arguments"] as? String, - let argsData = argsString.data(using: .utf8), - let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] + let name = function["name"] as? String else { return nil } + + let argsString = function["arguments"] as? String ?? "{}" + let args: [String: Any] + if argsString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args = [:] + } else if let argsData = argsString.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] + { + args = parsed + } else { + args = [:] + } + let id = tc["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))" return ToolCall(id: id, name: name, arguments: args) } @@ -599,6 +1569,27 @@ final class LLMClient { } } + private func shouldFallbackToChat(statusCode: Int, message: String) -> Bool { + if statusCode == 404 || statusCode == 405 { + return true + } + + if statusCode == 400 { + let lowered = message.lowercased() + if lowered.contains("unknown") && lowered.contains("input") { + return true + } + if lowered.contains("unknown") && lowered.contains("responses") { + return true + } + if lowered.contains("not supported") && lowered.contains("responses") { + return true + } + } + + return false + } + /// Check if a URL is a local/private endpoint private func isLocalEndpoint(_ urlString: String) -> Bool { guard let url = URL(string: urlString), @@ -652,7 +1643,9 @@ final class LLMClient { var curl = "curl -X \(method) \"\(url.absoluteString)\" \\\n" for (key, value) in request.allHTTPHeaderFields ?? [:] { - let maskedValue = key.lowercased().contains("auth") ? "Bearer [REDACTED]" : value + let loweredKey = key.lowercased() + let shouldMask = loweredKey.contains("auth") || loweredKey.contains("api-key") + let maskedValue = shouldMask ? "[REDACTED]" : value curl += " -H \"\(key): \(maskedValue)\" \\\n" } curl += " -d '\(bodyString)'" diff --git a/Sources/Fluid/Services/MCPManager.swift b/Sources/Fluid/Services/MCPManager.swift new file mode 100644 index 00000000..2f70947f --- /dev/null +++ b/Sources/Fluid/Services/MCPManager.swift @@ -0,0 +1,759 @@ +import Foundation +import MCP + +#if canImport(System) + import System +#else + import SystemPackage +#endif + +@MainActor +final class MCPManager { + static let shared = MCPManager() + private static let maxToolNameLength = 64 + + struct StatusSummary: Sendable { + let enabledServers: Int + let connectedServers: Int + let lastError: String? + } + + struct ToolExecutionContent: Codable, Hashable, Sendable { + let type: String + let text: String? + let data: String? + let mimeType: String? + let uri: String? + let metadata: [String: String] + } + + struct ToolExecutionResult: Sendable { + let success: Bool + let toolName: String + let serverID: String? + let output: String + let error: String? + let isError: Bool + let content: [ToolExecutionContent] + } + + private struct ToolRoute { + let serverID: String + let originalToolName: String + } + + private final class ServerRuntime { + let config: MCPSettingsStore.Server + let client: Client + let process: Process? + let stdInPipe: Pipe? + let stdOutPipe: Pipe? + let stdErrPipe: Pipe? + var tools: [Tool] + + init( + config: MCPSettingsStore.Server, + client: Client, + process: Process? = nil, + stdInPipe: Pipe? = nil, + stdOutPipe: Pipe? = nil, + stdErrPipe: Pipe? = nil, + tools: [Tool] + ) { + self.config = config + self.client = client + self.process = process + self.stdInPipe = stdInPipe + self.stdOutPipe = stdOutPipe + self.stdErrPipe = stdErrPipe + self.tools = tools + } + } + + enum MCPManagerError: LocalizedError { + case invalidURL(String) + case missingCommand(String) + case unknownTool(String) + case unavailableServer(String) + case invalidArguments(String) + case timeout(TimeInterval) + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "Invalid MCP server URL: \(url)" + case .missingCommand(let serverID): + return "MCP server '\(serverID)' requires a command" + case .unknownTool(let name): + return "Unknown MCP tool '\(name)'" + case .unavailableServer(let serverID): + return "MCP server '\(serverID)' is unavailable" + case .invalidArguments(let details): + return "Invalid MCP tool arguments: \(details)" + case .timeout(let seconds): + return "MCP operation timed out after \(Int(seconds))s" + } + } + } + + private let settingsStore = MCPSettingsStore.shared + + private var lastModifiedAt: Date? + private var settingsFileURLCache: URL? + private var enabledServerIDs: Set<String> = [] + private var connectionErrors: [String: String] = [:] + private var runtimes: [String: ServerRuntime] = [:] + private var cachedToolDefinitions: [[String: Any]] = [] + private var toolRoutes: [String: ToolRoute] = [:] + private var lastError: String? + + private init() {} + + func reloadConfiguration(force: Bool = false) async { + do { + let loaded = try await self.settingsStore.loadSettings(forceReload: force) + self.settingsFileURLCache = loaded.fileURL + + if !force, + let lastModifiedAt = self.lastModifiedAt, + lastModifiedAt == loaded.modifiedAt + { + return + } + + self.lastModifiedAt = loaded.modifiedAt + await self.applyConfiguration(loaded.document) + + } catch { + self.lastError = error.localizedDescription + self.enabledServerIDs = [] + self.connectionErrors = [:] + self.cachedToolDefinitions = [] + self.toolRoutes = [:] + await self.disconnectAllServers() + DebugLogger.shared.error( + "MCPManager: Failed loading settings.json: \(error.localizedDescription)", + source: "MCPManager") + } + } + + func toolDefinitions(reloadIfNeeded: Bool = true) async -> [[String: Any]] { + if reloadIfNeeded { + await self.reloadConfiguration(force: false) + } + return self.cachedToolDefinitions + } + + func settingsFileURL() async -> URL? { + if let url = self.settingsFileURLCache { + return url + } + if let ensuredURL = try? await self.settingsStore.ensureSettingsFileExists() { + self.settingsFileURLCache = ensuredURL + return ensuredURL + } + return nil + } + + func loadSettingsJSON() async throws -> String { + try await self.settingsStore.loadRawJSON() + } + + func validateSettingsJSON(_ json: String) async throws { + _ = try await self.settingsStore.validateJSON(json) + } + + func saveSettingsJSON(_ json: String) async throws { + try await self.settingsStore.saveRawJSON(json) + self.lastModifiedAt = nil + } + + func statusSummary(reloadIfNeeded: Bool = true) async -> StatusSummary { + if reloadIfNeeded { + await self.reloadConfiguration(force: false) + } + return self.currentStatusSummary() + } + + func callTool(functionName: String, arguments: [String: Any], reloadIfNeeded: Bool = true) async + -> ToolExecutionResult + { + if reloadIfNeeded { + await self.reloadConfiguration(force: false) + } + + guard let route = self.toolRoutes[functionName] else { + return ToolExecutionResult( + success: false, + toolName: functionName, + serverID: nil, + output: "", + error: MCPManagerError.unknownTool(functionName).localizedDescription, + isError: true, + content: [] + ) + } + + guard let runtime = self.runtimes[route.serverID] else { + return ToolExecutionResult( + success: false, + toolName: functionName, + serverID: route.serverID, + output: "", + error: MCPManagerError.unavailableServer(route.serverID).localizedDescription, + isError: true, + content: [] + ) + } + + do { + let mcpArguments = try Self.convertArguments(arguments) + let timeout = max(runtime.config.timeoutSeconds, 5) + let (content, isError) = try await self.withTimeout(seconds: timeout) { + try await runtime.client.callTool( + name: route.originalToolName, + arguments: mcpArguments.isEmpty ? nil : mcpArguments + ) + } + + let convertedContent = content.map(Self.convertToolContent) + let output = Self.flattenedOutput(from: convertedContent) + let toolErrored = isError ?? false + let success = !toolErrored + let fallbackError = toolErrored ? "MCP tool returned an error response." : nil + + return ToolExecutionResult( + success: success, + toolName: functionName, + serverID: route.serverID, + output: output, + error: fallbackError, + isError: toolErrored, + content: convertedContent + ) + + } catch { + return ToolExecutionResult( + success: false, + toolName: functionName, + serverID: route.serverID, + output: "", + error: error.localizedDescription, + isError: true, + content: [] + ) + } + } + + private func currentStatusSummary() -> StatusSummary { + return StatusSummary( + enabledServers: self.enabledServerIDs.count, + connectedServers: self.runtimes.count, + lastError: self.lastError + ) + } + + private func applyConfiguration(_ document: MCPSettingsStore.SettingsDocument) async { + self.lastError = nil + self.connectionErrors = [:] + + let enabledServers = document.servers.filter { $0.enabled } + self.enabledServerIDs = Set(enabledServers.map { $0.id }) + + let incomingByID = Dictionary(uniqueKeysWithValues: enabledServers.map { ($0.id, $0) }) + + // Disconnect removed or disabled servers. + let staleIDs = Set(self.runtimes.keys).subtracting(self.enabledServerIDs) + for staleID in staleIDs { + await self.disconnectServer(id: staleID) + } + + // Connect or refresh enabled servers. + for server in enabledServers { + if let runtime = self.runtimes[server.id], runtime.config == server { + continue + } + + await self.disconnectServer(id: server.id) + do { + let runtime = try await self.connectServer(server) + self.runtimes[server.id] = runtime + } catch { + self.connectionErrors[server.id] = error.localizedDescription + DebugLogger.shared.error( + "MCPManager: Failed connecting server '\(server.id)': \(error.localizedDescription)", + source: "MCPManager") + } + } + + // Disconnect servers no longer in incoming config. + let knownIDs = Set(incomingByID.keys) + let removedIDs = Set(self.runtimes.keys).subtracting(knownIDs) + for removedID in removedIDs { + await self.disconnectServer(id: removedID) + } + + self.rebuildToolCatalog() + + if !self.connectionErrors.isEmpty { + let joined = self.connectionErrors + .sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)" } + .joined(separator: " | ") + self.lastError = joined + } + } + + private func connectServer(_ server: MCPSettingsStore.Server) async throws -> ServerRuntime { + switch server.transport { + case .stdio: + return try await self.connectStdioServer(server) + case .http: + return try await self.connectHTTPServer(server) + } + } + + private func connectHTTPServer(_ server: MCPSettingsStore.Server) async throws -> ServerRuntime + { + guard let urlString = server.url, let endpoint = URL(string: urlString) else { + throw MCPManagerError.invalidURL(server.url ?? "") + } + + let requestHeaders = server.headers + let transport = HTTPClientTransport( + endpoint: endpoint, + streaming: false, + requestModifier: { request in + var request = request + for (key, value) in requestHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + return request + } + ) + + let client = Client(name: "FluidVoice", version: Self.clientVersion) + let timeout = max(server.timeoutSeconds, 5) + + _ = try await self.withTimeout(seconds: timeout) { + try await client.connect(transport: transport) + } + + let (tools, _) = try await self.withTimeout(seconds: timeout) { + try await client.listTools() + } + + DebugLogger.shared.info( + "MCPManager: Connected HTTP MCP server '\(server.id)' with \(tools.count) tools", + source: "MCPManager") + + return ServerRuntime( + config: server, + client: client, + tools: tools + ) + } + + private func connectStdioServer(_ server: MCPSettingsStore.Server) async throws -> ServerRuntime + { + guard let command = server.command?.trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty + else { + throw MCPManagerError.missingCommand(server.id) + } + + let process = Process() + let stdInPipe = Pipe() + let stdOutPipe = Pipe() + let stdErrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + server.args + process.standardInput = stdInPipe + process.standardOutput = stdOutPipe + process.standardError = stdErrPipe + + if let cwd = server.cwd, !cwd.isEmpty { + process.currentDirectoryURL = URL(fileURLWithPath: cwd, isDirectory: true) + } + + var environment = ProcessInfo.processInfo.environment + for (key, value) in server.env { + environment[key] = value + } + process.environment = environment + + let serverID = server.id + stdErrPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + let lines = + text + .split(whereSeparator: { $0.isNewline }) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines { + DebugLogger.shared.debug("MCP[\(serverID)] stderr: \(line)", source: "MCPManager") + } + } + + do { + try process.run() + } catch { + stdErrPipe.fileHandleForReading.readabilityHandler = nil + throw error + } + + let transport = StdioTransport( + input: .init(rawValue: stdOutPipe.fileHandleForReading.fileDescriptor), + output: .init(rawValue: stdInPipe.fileHandleForWriting.fileDescriptor) + ) + + let client = Client(name: "FluidVoice", version: Self.clientVersion) + let timeout = max(server.timeoutSeconds, 5) + + do { + _ = try await self.withTimeout(seconds: timeout) { + try await client.connect(transport: transport) + } + + let (tools, _) = try await self.withTimeout(seconds: timeout) { + try await client.listTools() + } + + DebugLogger.shared.info( + "MCPManager: Connected stdio MCP server '\(server.id)' with \(tools.count) tools", + source: "MCPManager") + + return ServerRuntime( + config: server, + client: client, + process: process, + stdInPipe: stdInPipe, + stdOutPipe: stdOutPipe, + stdErrPipe: stdErrPipe, + tools: tools + ) + } catch { + stdErrPipe.fileHandleForReading.readabilityHandler = nil + await client.disconnect() + if process.isRunning { + process.terminate() + } + throw error + } + } + + private func disconnectServer(id: String) async { + guard let runtime = self.runtimes.removeValue(forKey: id) else { return } + + await runtime.client.disconnect() + + runtime.stdErrPipe?.fileHandleForReading.readabilityHandler = nil + runtime.stdErrPipe?.fileHandleForReading.closeFile() + runtime.stdOutPipe?.fileHandleForReading.closeFile() + runtime.stdInPipe?.fileHandleForWriting.closeFile() + + if let process = runtime.process, process.isRunning { + process.terminate() + } + } + + private func disconnectAllServers() async { + let ids = Array(self.runtimes.keys) + for id in ids { + await self.disconnectServer(id: id) + } + } + + private func rebuildToolCatalog() { + self.cachedToolDefinitions = [] + self.toolRoutes = [:] + + var usedToolNames = Set<String>() + + for serverID in self.runtimes.keys.sorted() { + guard let runtime = self.runtimes[serverID] else { continue } + + for tool in runtime.tools { + let openAIName = self.uniqueToolName( + serverID: serverID, + toolName: tool.name, + usedToolNames: &usedToolNames + ) + + let definition = self.makeOpenAIToolDefinition( + openAIName: openAIName, + tool: tool, + serverID: serverID + ) + + self.cachedToolDefinitions.append(definition) + self.toolRoutes[openAIName] = ToolRoute( + serverID: serverID, originalToolName: tool.name) + } + } + } + + private func uniqueToolName( + serverID: String, toolName: String, usedToolNames: inout Set<String> + ) -> String { + let base = Self.sanitizeToolName("mcp_\(serverID)_\(toolName)") + let candidate = Self.makeUniqueSanitizedToolName(base: base, usedToolNames: &usedToolNames) + + return candidate + } + + static func makeUniqueSanitizedToolName(base: String, usedToolNames: inout Set<String>) -> String { + let truncatedBase = String(base.prefix(Self.maxToolNameLength)) + var candidate = truncatedBase + var counter = 2 + + while usedToolNames.contains(candidate) { + let suffix = "_\(counter)" + let reservedBaseLength = max(0, Self.maxToolNameLength - suffix.count) + candidate = String(truncatedBase.prefix(reservedBaseLength)) + suffix + counter += 1 + } + + usedToolNames.insert(candidate) + return candidate + } + + private static func sanitizeToolName(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-")) + var scalars: [UnicodeScalar] = [] + scalars.reserveCapacity(value.unicodeScalars.count) + + for scalar in value.unicodeScalars { + if allowed.contains(scalar) { + scalars.append(scalar) + } else { + scalars.append("_") + } + } + + var sanitized = String(String.UnicodeScalarView(scalars)) + while sanitized.contains("__") { + sanitized = sanitized.replacingOccurrences(of: "__", with: "_") + } + sanitized = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + + if sanitized.isEmpty { + sanitized = "mcp_tool" + } + + if let first = sanitized.first, first.isNumber { + sanitized = "mcp_\(sanitized)" + } + + if sanitized.count > Self.maxToolNameLength { + sanitized = String(sanitized.prefix(Self.maxToolNameLength)) + } + + return sanitized + } + + private func makeOpenAIToolDefinition(openAIName: String, tool: Tool, serverID: String) + -> [String: Any] + { + let schemaAny = Self.foundationValue(from: tool.inputSchema) + var parameters = schemaAny as? [String: Any] ?? [:] + if parameters["type"] == nil { + parameters["type"] = "object" + } + if parameters["properties"] == nil { + parameters["properties"] = [:] + } + + let description = tool.description ?? "MCP tool '\(tool.name)' from server '\(serverID)'." + + return [ + "type": "function", + "name": openAIName, + "description": description, + "parameters": parameters, + ] + } + + private static func convertToolContent(_ content: Tool.Content) -> ToolExecutionContent { + switch content { + case let .text(text, _, _): + return ToolExecutionContent( + type: "text", text: text, data: nil, mimeType: nil, uri: nil, metadata: [:]) + case let .image(data, mimeType, _, _): + return ToolExecutionContent( + type: "image", text: nil, data: data, mimeType: mimeType, uri: nil, metadata: [:]) + case let .audio(data, mimeType, _, _): + return ToolExecutionContent( + type: "audio", text: nil, data: data, mimeType: mimeType, uri: nil, metadata: [:]) + case let .resource(resource, _, _): + return ToolExecutionContent( + type: "resource", + text: resource.text, + data: resource.blob, + mimeType: resource.mimeType, + uri: resource.uri, + metadata: [:] + ) + case let .resourceLink(uri, name, title, description, mimeType, _): + let parts = [name, title, description].compactMap { $0 }.filter { !$0.isEmpty } + let summary = parts.isEmpty ? nil : parts.joined(separator: " - ") + return ToolExecutionContent( + type: "resource_link", + text: summary, + data: nil, + mimeType: mimeType, + uri: uri, + metadata: [:] + ) + } + } + + private static func flattenedOutput(from content: [ToolExecutionContent]) -> String { + var chunks: [String] = [] + chunks.reserveCapacity(content.count) + + for item in content { + switch item.type { + case "text": + if let text = item.text, !text.isEmpty { + chunks.append(text) + } + case "resource": + if let text = item.text, !text.isEmpty { + chunks.append(text) + } else if let uri = item.uri { + chunks.append("Resource: \(uri)") + } + case "image": + chunks.append("Image content (\(item.mimeType ?? "unknown mime"))") + case "audio": + chunks.append("Audio content (\(item.mimeType ?? "unknown mime"))") + default: + break + } + } + + return chunks.joined(separator: "\n") + } + + private static func foundationValue(from value: Value) -> Any { + switch value { + case .null: + return NSNull() + case .bool(let bool): + return bool + case .int(let int): + return int + case .double(let double): + return double + case .string(let string): + return string + case let .data(mimeType, data): + var payload: [String: Any] = [ + "type": "data", + "data": data.base64EncodedString(), + ] + if let mimeType { + payload["mimeType"] = mimeType + } + return payload + case .array(let array): + return array.map(Self.foundationValue(from:)) + case .object(let object): + var mapped: [String: Any] = [:] + mapped.reserveCapacity(object.count) + for (key, nestedValue) in object { + mapped[key] = Self.foundationValue(from: nestedValue) + } + return mapped + } + } + + private static func convertArguments(_ arguments: [String: Any]) throws -> [String: Value] { + var converted: [String: Value] = [:] + converted.reserveCapacity(arguments.count) + + for (key, rawValue) in arguments { + converted[key] = try self.mcpValue(from: rawValue) + } + + return converted + } + + private static func mcpValue(from rawValue: Any) throws -> Value { + switch rawValue { + case is NSNull: + return .null + + case let value as Bool: + return .bool(value) + + case let value as Int: + return .int(value) + + case let value as Double: + return .double(value) + + case let value as Float: + return .double(Double(value)) + + case let value as String: + return .string(value) + + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return .bool(number.boolValue) + } + let doubleValue = number.doubleValue + if floor(doubleValue) == doubleValue { + return .int(number.intValue) + } + return .double(doubleValue) + + case let array as [Any]: + return .array(try array.map { try self.mcpValue(from: $0) }) + + case let dictionary as [String: Any]: + var converted: [String: Value] = [:] + converted.reserveCapacity(dictionary.count) + for (key, nestedValue) in dictionary { + converted[key] = try self.mcpValue(from: nestedValue) + } + return .object(converted) + + default: + throw MCPManagerError.invalidArguments( + "Unsupported argument type: \(type(of: rawValue))") + } + } + + private func withTimeout<T>( + seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + let timeoutNanoseconds = UInt64(max(1, Int(seconds.rounded())) * 1_000_000_000) + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + throw MCPManagerError.timeout(seconds) + } + + guard let first = try await group.next() else { + throw MCPManagerError.timeout(seconds) + } + group.cancelAll() + return first + } + } + + private static var clientVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1" + } +} diff --git a/Sources/Fluid/Services/MCPSettingsStore.swift b/Sources/Fluid/Services/MCPSettingsStore.swift new file mode 100644 index 00000000..b6e79f9c --- /dev/null +++ b/Sources/Fluid/Services/MCPSettingsStore.swift @@ -0,0 +1,381 @@ +import Foundation + +actor MCPSettingsStore { + static let shared = MCPSettingsStore() + + struct SettingsDocument: Decodable, Equatable, Sendable { + var version: Int + var servers: [Server] + + private enum CodingKeys: String, CodingKey { + case version + case servers + case mcpServers + } + + init(version: Int = 1, servers: [Server] = []) { + self.version = version + self.servers = servers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + + let mcpServers = + try container.decodeIfPresent([String: Server].self, forKey: .mcpServers) ?? [:] + self.servers = mcpServers.keys.sorted().compactMap { mcpServers[$0] } + } + } + + struct Server: Decodable, Equatable, Sendable { + enum Transport: String, Codable, Sendable { + case stdio + case http + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case enabled + case disabled + case transport + case type + case command + case args + case env + case cwd + case url + case headers + case timeoutSeconds + } + + var id: String + var name: String? + var enabled: Bool + var transport: Transport + + // stdio + var command: String? + var args: [String] + var env: [String: String] + var cwd: String? + + // http + var url: String? + var headers: [String: String] + + var timeoutSeconds: TimeInterval + + init( + id: String, + name: String? = nil, + enabled: Bool = true, + transport: Transport, + command: String? = nil, + args: [String] = [], + env: [String: String] = [:], + cwd: String? = nil, + url: String? = nil, + headers: [String: String] = [:], + timeoutSeconds: TimeInterval = 30 + ) { + self.id = id + self.name = name + self.enabled = enabled + self.transport = transport + self.command = command + self.args = args + self.env = env + self.cwd = cwd + self.url = url + self.headers = headers + self.timeoutSeconds = timeoutSeconds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let explicitID = try container.decodeIfPresent(String.self, forKey: .id) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + + let pathID: String? = { + guard let last = decoder.codingPath.last, last.intValue == nil else { return nil } + let key = last.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : key + }() + + self.id = pathID ?? explicitID ?? "" + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + let disabled = try container.decodeIfPresent(Bool.self, forKey: .disabled) ?? false + self.enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? !disabled + + if let transport = try container.decodeIfPresent(Transport.self, forKey: .transport) { + self.transport = transport + } else if let type = try container.decodeIfPresent(String.self, forKey: .type) { + let normalizedType = type.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch normalizedType { + case "stdio", "": + self.transport = .stdio + case "http", "streamable-http": + self.transport = .http + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: + "Unsupported MCP server type '\(type)'. Supported values are 'stdio' and 'http'." + ) + } + } else if try container.decodeIfPresent(String.self, forKey: .url) != nil { + self.transport = .http + } else { + self.transport = .stdio + } + + self.command = try container.decodeIfPresent(String.self, forKey: .command) + self.args = try container.decodeIfPresent([String].self, forKey: .args) ?? [] + self.env = try container.decodeIfPresent([String: String].self, forKey: .env) ?? [:] + self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + self.url = try container.decodeIfPresent(String.self, forKey: .url) + self.headers = + try container.decodeIfPresent([String: String].self, forKey: .headers) ?? [:] + self.timeoutSeconds = + try container.decodeIfPresent(TimeInterval.self, forKey: .timeoutSeconds) ?? 30 + } + } + + struct LoadedSettings: Sendable { + let document: SettingsDocument + let fileURL: URL + let modifiedAt: Date + } + + enum StoreError: LocalizedError { + case applicationSupportUnavailable + case invalidJSON(String) + case invalidConfiguration(String) + + var errorDescription: String? { + switch self { + case .applicationSupportUnavailable: + return "Could not access Application Support directory for MCP settings." + case .invalidJSON(let details): + return "Invalid MCP settings.json: \(details)" + case .invalidConfiguration(let details): + return "Invalid MCP settings configuration: \(details)" + } + } + } + + private let fileName = "settings.json" + private let appSupportFolder = "FluidVoice" + private let bundledTemplateName = "mcp.settings.default" + private var cachedSettings: LoadedSettings? + + private init() {} + + func settingsFileURL() throws -> URL { + guard + let baseDirectory = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first + else { + throw StoreError.applicationSupportUnavailable + } + let directory = baseDirectory.appendingPathComponent( + self.appSupportFolder, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent(self.fileName, isDirectory: false) + } + + @discardableResult + func ensureSettingsFileExists() throws -> URL { + let fileURL = try self.settingsFileURL() + guard !FileManager.default.fileExists(atPath: fileURL.path) else { return fileURL } + + if let bundledURL = Bundle.main.url( + forResource: self.bundledTemplateName, withExtension: "json"), + let data = try? Data(contentsOf: bundledURL) + { + try data.write(to: fileURL, options: .atomic) + } else { + let template = Self.defaultTemplateJSON() + try template.write(to: fileURL, atomically: true, encoding: .utf8) + } + + return fileURL + } + + func loadRawJSON() throws -> String { + let fileURL = try self.ensureSettingsFileExists() + return try String(contentsOf: fileURL, encoding: .utf8) + } + + func validateJSON(_ json: String) throws -> SettingsDocument { + let data = Data(json.utf8) + let decoder = JSONDecoder() + + let decoded: SettingsDocument + do { + decoded = try decoder.decode(SettingsDocument.self, from: data) + } catch { + throw StoreError.invalidJSON(error.localizedDescription) + } + + return try self.validateAndNormalize(decoded) + } + + func saveRawJSON(_ json: String) throws { + _ = try self.validateJSON(json) + let fileURL = try self.ensureSettingsFileExists() + try json.write(to: fileURL, atomically: true, encoding: .utf8) + self.cachedSettings = nil + } + + func loadSettings(forceReload: Bool = false) throws -> LoadedSettings { + let fileURL = try self.ensureSettingsFileExists() + let modifiedAt = self.modifiedDate(for: fileURL) + + if !forceReload, + let cachedSettings, + cachedSettings.fileURL == fileURL, + cachedSettings.modifiedAt == modifiedAt + { + return cachedSettings + } + + let data = try Data(contentsOf: fileURL) + let decoder = JSONDecoder() + + let decoded: SettingsDocument + do { + decoded = try decoder.decode(SettingsDocument.self, from: data) + } catch { + throw StoreError.invalidJSON(error.localizedDescription) + } + + let validated = try self.validateAndNormalize(decoded) + let loaded = LoadedSettings(document: validated, fileURL: fileURL, modifiedAt: modifiedAt) + self.cachedSettings = loaded + return loaded + } + + private func validateAndNormalize(_ document: SettingsDocument) throws -> SettingsDocument { + guard document.version == 1 else { + throw StoreError.invalidConfiguration( + "Unsupported settings version \(document.version). Expected version 1.") + } + + var normalizedServers: [Server] = [] + var seenIDs = Set<String>() + + for var server in document.servers { + let id = server.id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { + throw StoreError.invalidConfiguration( + "Server id must not be empty. Use a non-empty key under 'mcpServers'.") + } + let normalizedID = id.lowercased() + guard !seenIDs.contains(normalizedID) else { + throw StoreError.invalidConfiguration("Duplicate server id '\(id)'") + } + seenIDs.insert(normalizedID) + server.id = id + + if let name = server.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + server.name = name + } else { + server.name = nil + } + + server.timeoutSeconds = min(max(server.timeoutSeconds, 5), 300) + + switch server.transport { + case .stdio: + let command = server.command?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !command.isEmpty else { + throw StoreError.invalidConfiguration( + "Server '\(id)' (stdio) requires 'command'.") + } + server.command = command + server.args = server.args + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if let cwd = server.cwd?.trimmingCharacters(in: .whitespacesAndNewlines), + !cwd.isEmpty + { + server.cwd = cwd + } else { + server.cwd = nil + } + server.url = nil + + case .http: + let urlString = server.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + throw StoreError.invalidConfiguration( + "Server '\(id)' (http) requires a valid http(s) 'url'.") + } + server.url = urlString + server.command = nil + server.args = [] + server.cwd = nil + } + + server.env = Dictionary( + uniqueKeysWithValues: server.env.compactMap { key, value in + let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty else { return nil } + return (trimmedKey, value) + }) + + server.headers = Dictionary( + uniqueKeysWithValues: server.headers.compactMap { key, value in + let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty else { return nil } + return (trimmedKey, value) + }) + + normalizedServers.append(server) + } + + return SettingsDocument(version: 1, servers: normalizedServers) + } + + private func modifiedDate(for fileURL: URL) -> Date { + if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let modified = attrs[.modificationDate] as? Date + { + return modified + } + return .distantPast + } + + private static func defaultTemplateJSON() -> String { + """ + { + "mcpServers": { + "altic-mcp": { + "enabled": false, + "command": "uv", + "args": [ + "run", + "--project", + "/FULL/PATH/TO/altic-mcp", + "/FULL/PATH/TO/altic-mcp/server.py" + ], + "env": {} + } + } + } + """ + } +} diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index c438fc64..9e99e486 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -5,6 +5,7 @@ import SwiftUI enum MenuBarNavigationDestination: String { case preferences + case commandMode } @MainActor @@ -178,6 +179,12 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { return } + // Keep compact command completion badge visible for its short acknowledgment duration. + if NotchOverlayManager.shared.isCommandCompletionBadgeVisible { + self.pendingHideOperation = nil + return + } + let hideItem = DispatchWorkItem { [weak self] in guard let self = self, !self.overlayVisible else { return } @@ -187,6 +194,12 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { return } + // Don't hide while command completion badge is active. + if NotchOverlayManager.shared.isCommandCompletionBadgeVisible { + self.pendingHideOperation = nil + return + } + // Hide notch overlay NotchOverlayManager.shared.hide() @@ -239,6 +252,13 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { return } + // If command completion badge is showing, let it auto-hide itself. + if NotchOverlayManager.shared.isCommandCompletionBadgeVisible { + self.pendingHideOperation = nil + NotchOverlayManager.shared.setProcessing(processing) + return + } + let hideItem = DispatchWorkItem { [weak self] in guard let self = self, !self.overlayVisible else { return } @@ -248,6 +268,11 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { return } + if NotchOverlayManager.shared.isCommandCompletionBadgeVisible { + self.pendingHideOperation = nil + return + } + NotchOverlayManager.shared.hide() self.pendingHideOperation = nil } @@ -674,11 +699,28 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } } + @objc private func openCommandMode() { + self.requestedNavigationDestination = nil + self.requestedNavigationDestination = .commandMode + + self.openMainWindow() + + DispatchQueue.main.async { [weak self] in + self?.requestedNavigationDestination = nil + self?.requestedNavigationDestination = .commandMode + } + } + /// Public entry-point for non-menu UI surfaces (e.g. overlay controls) to open Preferences. func openPreferencesFromUI() { self.openPreferences() } + /// Public entry-point for notch interactions to open Command Mode. + func openCommandModeFromUI() { + self.openCommandMode() + } + /// Create and present a fresh main window hosting `ContentView` private func createAndShowMainWindow() { // Build the SwiftUI root view with required environment diff --git a/Sources/Fluid/Services/NotchOverlayManager.swift b/Sources/Fluid/Services/NotchOverlayManager.swift index b02aebfe..4ff8f292 100644 --- a/Sources/Fluid/Services/NotchOverlayManager.swift +++ b/Sources/Fluid/Services/NotchOverlayManager.swift @@ -85,10 +85,46 @@ final class NotchOverlayManager { /// Track pending retry task for cancellation private var pendingRetryTask: Task<Void, Never>? + /// Short-lived compact completion indicator (command mode) + private var commandCompletionBadgeTask: Task<Void, Never>? + private(set) var isCommandCompletionBadgeVisible: Bool = false + // Cancel shortcut monitors for dismissing notch / overlay private var globalEscapeMonitor: Any? private var localEscapeMonitor: Any? + enum LiveOverlayPresentation: Equatable { + case notchOverlay + case bottomOverlay + } + + enum CommandCompletionFeedbackPresentation: Equatable { + case notchBadge + case systemNotification + } + + static func liveOverlayPresentation( + overlayPosition: SettingsStore.OverlayPosition, + activeScreenHasHardwareNotch: Bool + ) -> LiveOverlayPresentation { + switch overlayPosition { + case .bottom: + return .bottomOverlay + case .top: + return activeScreenHasHardwareNotch ? .notchOverlay : .bottomOverlay + } + } + + static func commandCompletionFeedbackPresentation( + overlayPosition: SettingsStore.OverlayPosition, + activeScreenHasHardwareNotch: Bool + ) -> CommandCompletionFeedbackPresentation { + guard overlayPosition == .top, activeScreenHasHardwareNotch else { + return .systemNotification + } + return .notchBadge + } + private(set) var currentNotchPresentationMode: SettingsStore.NotchPresentationMode = .standard private(set) var currentNotchPresentationPolicy = NotchPresentationPolicy.standard private(set) var currentScreenSupportsCompactPresentation = false @@ -150,6 +186,9 @@ final class NotchOverlayManager { return } + // Any fresh recording/overlay session should clear the command completion badge. + self.clearCommandCompletionBadgeState() + // Cancel any pending retry operations self.pendingRetryTask?.cancel() self.pendingRetryTask = nil @@ -192,14 +231,17 @@ final class NotchOverlayManager { ActiveAppMonitor.shared.startMonitoring() let targetScreen = OverlayScreenResolver.screenForCurrentPointer() - // Route to bottom overlay if user preference is set - if SettingsStore.shared.overlayPosition == .bottom { + let presentation = Self.liveOverlayPresentation( + overlayPosition: SettingsStore.shared.overlayPosition, + activeScreenHasHardwareNotch: self.screenHasHardwareNotch(targetScreen) + ) + + switch presentation { + case .bottomOverlay: self.showBottomOverlay(audioLevelPublisher: audioLevelPublisher, mode: mode) - return + case .notchOverlay: + self.showNotchOverlay(audioLevelPublisher: audioLevelPublisher, mode: mode, screen: targetScreen) } - - // Otherwise show notch overlay (original behavior) - self.showNotchOverlay(audioLevelPublisher: audioLevelPublisher, mode: mode, screen: targetScreen) } /// Show bottom overlay (alternative to notch) @@ -217,7 +259,12 @@ final class NotchOverlayManager { } /// Show notch overlay (original behavior) - private func showNotchOverlay(audioLevelPublisher: AnyPublisher<CGFloat, Never>, mode: OverlayMode, screen: NSScreen?) { + private func showNotchOverlay( + audioLevelPublisher: AnyPublisher<CGFloat, Never>, + mode: OverlayMode, + screen: NSScreen?, + initiallyCompact: Bool = false + ) { let targetScreen = screen ?? self.preferredPresentationScreen() self.presentationPolicyScreen = targetScreen self.refreshNotchPresentationPolicy(for: targetScreen) @@ -254,19 +301,27 @@ final class NotchOverlayManager { NotchCompactBottomView() } + newNotch.transitionConfiguration = DynamicNotchTransitionConfiguration(skipIntermediateHides: true) + self.notch = newNotch let shouldUseCompactPresentation = self.currentNotchPresentationPolicy.usesCompactPresentation // Resolve presentation from policy so future notch modes don't require call-site changes. Task { [weak self] in - if shouldUseCompactPresentation { + if initiallyCompact || shouldUseCompactPresentation { await newNotch.compact(on: targetScreen) } else { await newNotch.expand(on: targetScreen) } // Only update state if we're still the active generation guard let self = self, self.generation == currentGeneration else { return } - self.state = .visible + + if newNotch.windowController?.window != nil { + self.state = .visible + } else { + self.notch = nil + self.state = .idle + } } } @@ -274,6 +329,8 @@ final class NotchOverlayManager { // Stop monitoring active app changes ActiveAppMonitor.shared.stopMonitoring() + self.clearCommandCompletionBadgeState() + // Hide bottom overlay if visible if self.isBottomOverlayVisible { BottomOverlayWindowController.shared.hide() @@ -315,6 +372,8 @@ final class NotchOverlayManager { self.pendingRetryTask?.cancel() self.pendingRetryTask = nil + self.clearCommandCompletionBadgeState() + if let existingNotch = notch { await existingNotch.hide() } @@ -361,6 +420,10 @@ final class NotchOverlayManager { func setProcessing(_ processing: Bool) { NotchContentState.shared.setProcessing(processing) + if processing { + self.clearCommandCompletionBadgeState() + } + // If expanded command output is showing, don't mess with regular notch if self.isCommandOutputExpanded { return @@ -384,12 +447,129 @@ final class NotchOverlayManager { // MARK: - Expanded Command Output + func showCommandCompletionBadge(success: Bool) { + guard !self.isCommandOutputExpanded else { return } + + self.pendingRetryTask?.cancel() + self.pendingRetryTask = nil + + // Reset any existing badge state before deciding this completion's feedback mode. + self.clearCommandCompletionBadgeState() + + let completionPresentation = Self.commandCompletionFeedbackPresentation( + overlayPosition: SettingsStore.shared.overlayPosition, + activeScreenHasHardwareNotch: self.activeScreenHasHardwareNotch() + ) + + if success, completionPresentation == .systemNotification { + SystemNotificationService.shared.showCommandSuccessNotification() + return + } + + if self.isBottomOverlayVisible { + BottomOverlayWindowController.shared.hide() + self.isBottomOverlayVisible = false + } + + self.currentMode = .command + NotchContentState.shared.mode = .command + NotchContentState.shared.setCommandTurnBadge(success: success) + self.isCommandCompletionBadgeVisible = true + + let badgePublisher = self.lastAudioPublisher ?? Empty<CGFloat, Never>().eraseToAnyPublisher() + + let shouldStartCompactFromHidden = self.notch == nil || self.state == .idle + + if shouldStartCompactFromHidden { + self.showNotchOverlay( + audioLevelPublisher: badgePublisher, + mode: .command, + screen: self.activeScreenForCompletionFeedback(), + initiallyCompact: true + ) + } + + let holdDurationNs: UInt64 = success ? 2_400_000_000 : 2_000_000_000 + + self.commandCompletionBadgeTask = Task { [weak self] in + guard let self else { return } + + if !shouldStartCompactFromHidden { + // Give the expanded notch a brief moment to settle before compacting. + try? await Task.sleep(nanoseconds: 120_000_000) + guard !Task.isCancelled else { return } + + if let currentNotch = self.notch { + if let screen = self.activeScreenForCompletionFeedback() { + await currentNotch.compact(on: screen) + } else { + await currentNotch.compact() + } + guard !Task.isCancelled else { return } + + if currentNotch.windowController?.window != nil { + self.state = .visible + } else { + self.notch = nil + self.state = .idle + } + } + } + + try? await Task.sleep(nanoseconds: holdDurationNs) + guard !Task.isCancelled else { return } + + self.clearCommandCompletionBadgeState(cancelTask: false) + self.hide() + } + } + + private func clearCommandCompletionBadgeState(cancelTask: Bool = true) { + if cancelTask { + self.commandCompletionBadgeTask?.cancel() + self.commandCompletionBadgeTask = nil + } + self.isCommandCompletionBadgeVisible = false + NotchContentState.shared.clearCommandTurnBadge() + } + + private func activeScreenHasHardwareNotch() -> Bool { + guard let activeScreen = self.activeScreenForCompletionFeedback() else { return false } + return self.screenHasHardwareNotch(activeScreen) + } + + private func activeScreenForCompletionFeedback() -> NSScreen? { + if let notchScreen = self.notch?.windowController?.window?.screen { + return notchScreen + } + + let mouseLocation = NSEvent.mouseLocation + if let mouseScreen = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) { + return mouseScreen + } + + return NSScreen.main ?? NSScreen.screens.first + } + + private func screenHasHardwareNotch(_ screen: NSScreen?) -> Bool { + guard let screen else { return false } + return self.screenHasHardwareNotch(screen) + } + + private func screenHasHardwareNotch(_ screen: NSScreen) -> Bool { + screen.auxiliaryTopLeftArea?.width != nil && screen.auxiliaryTopRightArea?.width != nil + } + /// Show expanded command output notch func showExpandedCommandOutput() { guard self.canShowExpandedCommandOutput else { return } - // Hide regular notch first if visible - if self.notch != nil { + self.clearCommandCompletionBadgeState() + + // Hide any recording overlay first (regular notch or bottom overlay). + // Otherwise, expanded command output can race with deferred hide operations, + // leaving the bottom overlay visible underneath. + if self.notch != nil || self.isBottomOverlayVisible || self.state != .idle { self.hide() } diff --git a/Sources/Fluid/Services/RewriteModeService.swift b/Sources/Fluid/Services/RewriteModeService.swift index 83e25d07..97114144 100644 --- a/Sources/Fluid/Services/RewriteModeService.swift +++ b/Sources/Fluid/Services/RewriteModeService.swift @@ -331,6 +331,7 @@ final class RewriteModeService: ObservableObject { // Build LLMClient configuration var config = LLMClient.Config( messages: apiMessages, + providerID: providerID, model: model, baseURL: baseURL, apiKey: apiKey, diff --git a/Sources/Fluid/Services/SystemNotificationService.swift b/Sources/Fluid/Services/SystemNotificationService.swift new file mode 100644 index 00000000..2e02317a --- /dev/null +++ b/Sources/Fluid/Services/SystemNotificationService.swift @@ -0,0 +1,82 @@ +import Foundation +import UserNotifications + +final class SystemNotificationService: NSObject, UNUserNotificationCenterDelegate { + static let shared = SystemNotificationService() + + static var foregroundPresentationOptions: UNNotificationPresentationOptions { + [.banner, .sound] + } + + private let notificationCenter: UNUserNotificationCenter + + private override init() { + self.notificationCenter = UNUserNotificationCenter.current() + super.init() + self.notificationCenter.delegate = self + } + + func showCommandSuccessNotification() { + Task { [weak self] in + guard let self else { return } + guard await self.ensureNotificationAuthorization() else { return } + + let content = UNMutableNotificationContent() + content.title = "FluidVoice" + content.body = "Command completed successfully." + content.sound = .default + + let request = UNNotificationRequest( + identifier: "fluid.command.success.\(UUID().uuidString)", + content: content, + trigger: nil + ) + + do { + try await self.notificationCenter.add(request) + } catch { + DebugLogger.shared.error( + "Failed to post command success notification: \(error.localizedDescription)", + source: "SystemNotificationService" + ) + } + } + } + + private func ensureNotificationAuthorization() async -> Bool { + let settings = await self.notificationCenter.notificationSettings() + + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + + case .notDetermined: + do { + return try await self.notificationCenter.requestAuthorization(options: [.alert, .sound]) + } catch { + DebugLogger.shared.error( + "Failed to request notification authorization: \(error.localizedDescription)", + source: "SystemNotificationService" + ) + return false + } + + case .denied: + DebugLogger.shared.debug( + "Skipping command success notification because notifications are denied", + source: "SystemNotificationService" + ) + return false + + @unknown default: + return false + } + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + Self.foregroundPresentationOptions + } +} diff --git a/Sources/Fluid/Services/TerminalService.swift b/Sources/Fluid/Services/TerminalService.swift index 47bf79d4..3789c4a1 100644 --- a/Sources/Fluid/Services/TerminalService.swift +++ b/Sources/Fluid/Services/TerminalService.swift @@ -5,7 +5,7 @@ import Foundation final class TerminalService { // MARK: - JSON Response Types - struct CommandResult: Codable { + struct CommandResult: Codable, Sendable { let success: Bool let command: String let output: String @@ -20,9 +20,8 @@ final class TerminalService { static var toolDefinition: [String: Any] { return [ "type": "function", - "function": [ - "name": "execute_terminal_command", - "description": """ + "name": "execute_terminal_command", + "description": """ Execute a terminal/shell command on the user's macOS computer. Use this for file operations (ls, cat, mkdir, rm), git commands, brew, npm, python, or any CLI tool. @@ -33,30 +32,29 @@ final class TerminalService { Returns JSON with: success (bool), output (stdout), error (stderr), exitCode, purpose. """, - "parameters": [ - "type": "object", - "properties": [ - "command": [ - "type": "string", - "description": "The shell command to execute (e.g., 'ls -la', 'git status', 'rm file.txt')", - ], - "workingDirectory": [ - "type": "string", - "description": "Optional working directory path. Defaults to user's home directory.", - ], - "purpose": [ - "type": "string", - "description": """ + "parameters": [ + "type": "object", + "properties": [ + "command": [ + "type": "string", + "description": "The shell command to execute (e.g., 'ls -la', 'git status', 'rm file.txt')", + ], + "workingDirectory": [ + "type": "string", + "description": "Optional working directory path. Defaults to user's home directory.", + ], + "purpose": [ + "type": "string", + "description": """ Brief description of why this command is being run. Must be one of: - 'checking' (verifying prerequisites) - 'executing' (main action) - 'verifying' (confirming result) Example: 'Checking if config.json exists' """, - ], ], - "required": ["command", "purpose"], ], + "required": ["command", "purpose"], ], ] } @@ -69,6 +67,20 @@ final class TerminalService { workingDirectory: String? = nil, timeout: TimeInterval = 30 ) async -> CommandResult { + return await Task.detached(priority: .utility) { + Self.executeOffMain( + command: command, + workingDirectory: workingDirectory, + timeout: timeout + ) + }.value + } + + private static func executeOffMain( + command: String, + workingDirectory: String?, + timeout: TimeInterval + ) -> CommandResult { let startTime = Date() let process = Process() diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 89cd2f86..5aedac88 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -587,10 +587,10 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } else { fullURL = endpoint + "/messages" } - } else if endpoint.contains("/chat/completions") || endpoint.contains("/api/chat") || endpoint.contains("/api/generate") { + } else if endpoint.contains("/responses") || endpoint.contains("/chat/completions") || endpoint.contains("/api/chat") || endpoint.contains("/api/generate") { fullURL = endpoint } else { - fullURL = endpoint + "/chat/completions" + fullURL = endpoint + "/responses" } // Debug logging @@ -627,6 +627,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let requestDict: [String: Any] let provKey = self.providerKey(for: providerID) let reasoningConfig = self.settings.getReasoningConfig(forModel: trimmedModel, provider: provKey) + let usesChatCompletions = fullURL.contains("/chat/completions") || fullURL.contains("/api/chat") || fullURL.contains("/api/generate") if isAnthropic { // Anthropic API format @@ -635,8 +636,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { "max_tokens": 10, "messages": [["role": "user", "content": "Hi"]], ] - } else { - // OpenAI-compatible format + } else if usesChatCompletions { var dict: [String: Any] = [ "model": trimmedModel, "messages": [["role": "user", "content": "test"]], @@ -656,6 +656,25 @@ final class AIEnhancementSettingsViewModel: ObservableObject { dict[config.parameterName] = config.parameterValue } } + + requestDict = dict + } else { + var dict: [String: Any] = [ + "model": trimmedModel, + "input": [["role": "user", "content": "test"]], + "max_output_tokens": 50, + ] + + if let config = reasoningConfig, config.isEnabled { + if config.parameterName == "reasoning_effort" { + dict["reasoning"] = ["effort": config.parameterValue] + } else if config.parameterName == "enable_thinking" { + dict[config.parameterName] = config.parameterValue == "true" + } else { + dict[config.parameterName] = config.parameterValue + } + } + requestDict = dict } @@ -734,7 +753,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { if responseBody.contains("model") { return "Invalid model '\(model)' for \(providerName). Check if the model name is correct." } - if responseBody.contains("messages") || responseBody.contains("content") { + if responseBody.contains("messages") || responseBody.contains("content") || responseBody.contains("input") { return "Request format error for \(providerName). The API may have changed or the base URL is pointed at a different API." } return "Bad request (HTTP 400). Check that the base URL '\(endpoint)' is correct for \(providerName)." diff --git a/Sources/Fluid/Views/CommandModeView.swift b/Sources/Fluid/Views/CommandModeView.swift index 355062ea..f4b7aa21 100644 --- a/Sources/Fluid/Views/CommandModeView.swift +++ b/Sources/Fluid/Views/CommandModeView.swift @@ -1,3 +1,5 @@ +import AppKit +import MarkdownUI import SwiftUI struct CommandModeView: View { @@ -16,6 +18,8 @@ struct CommandModeView: View { @State private var showingClearConfirmation = false @State private var showHowTo = false @State private var isHoveringHowTo = false + @State private var isReloadingMCP = false + @State private var showingMCPEditor = false @Environment(\.theme) private var theme @@ -32,7 +36,7 @@ struct CommandModeView: View { // Chat Area self.chatArea - // Pending Command Confirmation (if any) + // Pending Tool Confirmation (if any) if let pending = service.pendingCommand { self.pendingCommandView(pending) } @@ -46,6 +50,7 @@ struct CommandModeView: View { self.updateAvailableModels() // Disable notch output when using in-app UI (conversation is shared but notch shouldn't show) self.service.enableNotchOutput = false + Task { await self.service.refreshMCPStatus() } } .onDisappear { // Re-enable notch output when leaving in-app UI @@ -59,6 +64,9 @@ struct CommandModeView: View { .onChange(of: self.settings.commandModeSelectedProviderID) { _, _ in self.updateAvailableModels() } + .sheet(isPresented: self.$showingMCPEditor) { + MCPSettingsEditorView(service: self.service) + } .onExitCommand { self.onClose?() } @@ -67,7 +75,7 @@ struct CommandModeView: View { // MARK: - Header private var headerView: some View { - HStack { + VStack(spacing: 10) { HStack(spacing: 8) { Text("Command Mode") .font(.title2) @@ -79,27 +87,27 @@ struct CommandModeView: View { .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Color(red: 1.0, green: 0.35, blue: 0.35)) // Command mode red + .background(Color(red: 1.0, green: 0.35, blue: 0.35)) // Command mode red .cornerRadius(4) - } - Spacer() + Spacer() + } - // Chat management buttons - HStack(spacing: 4) { - // New Chat Button + HStack(spacing: 8) { Button(action: { self.service.createNewChat() }) { - Image(systemName: "plus") + self.headerControlChip { + Image(systemName: "square.and.pencil") + .font(.system(size: 12, weight: .semibold)) + } } - .buttonStyle(.bordered) - .help("New chat") + .buttonStyle(.plain) .disabled(self.service.isProcessing) + .help("Create new session") - // Recent Chats Menu Menu { let recentChats = self.service.getRecentChats() if recentChats.isEmpty { - Text("No recent chats") + Text("No session history") .foregroundStyle(.secondary) } else { ForEach(recentChats) { chat in @@ -125,37 +133,142 @@ struct CommandModeView: View { } } } label: { - Image(systemName: "clock.arrow.circlepath") + self.headerControlChip { + HStack(spacing: 4) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 12, weight: .semibold)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + } } - .menuStyle(.borderlessButton) - .frame(width: 32, height: 24) - .help("Recent chats") + .menuIndicator(.hidden) + .buttonStyle(.plain) + .disabled(self.service.isProcessing) + .help("Browse session history") + + Text(self.currentSessionTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 180, alignment: .leading) - // Delete Chat Button - deletes the current chat entirely - Button(action: { self.showingClearConfirmation = true }) { - Image(systemName: "trash") + Spacer(minLength: 8) + + Menu { + Button { + self.settings.commandModeConfirmBeforeExecute = false + } label: { + Label("Auto-approve tools", systemImage: "checkmark.shield") + } + + Button { + self.settings.commandModeConfirmBeforeExecute = true + } label: { + Label("Require confirmation", systemImage: "shield") + } + } label: { + self.headerControlChip { + HStack(spacing: 6) { + Image(systemName: self.approvalModeIcon) + .font(.system(size: 12, weight: .semibold)) + Text(self.approvalModeText) + .font(.caption) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(self.approvalModeColor) + } + } + .menuIndicator(.hidden) + .buttonStyle(.plain) + .help("Tool approval mode") + + if self.service.pendingCommand != nil { + Button(action: { + Task { await self.service.confirmAndExecute() } + }) { + self.headerControlChip { + Label("Confirm", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + .buttonStyle(.plain) + .help("Confirm and run pending tool call") } - .buttonStyle(.bordered) - .help("Delete chat") - .disabled(self.service.isProcessing) - } - Divider() - .frame(height: 20) - .padding(.horizontal, 8) + Menu { + Text(self.mcpStatusText) + .font(.caption) - // Confirm Before Execute Toggle - Toggle(isOn: self.$settings.commandModeConfirmBeforeExecute) { - Label("Confirm", systemImage: "checkmark.shield") - .font(.caption) + Button("Edit settings.json") { + self.showingMCPEditor = true + } + + Button("Open settings.json") { + self.openMCPSettingsFile() + } + Button("Reveal settings.json") { + self.revealMCPSettingsFile() + } + Button(self.isReloadingMCP ? "Reloading..." : "Reload MCP") { + self.reloadMCPConfiguration() + } + .disabled(self.isReloadingMCP) + + if let lastError = self.service.mcpLastError, !lastError.isEmpty { + Divider() + Text("Last error: \(lastError)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } label: { + self.headerControlChip { + HStack(spacing: 6) { + Image(systemName: "server.rack") + .font(.system(size: 12, weight: .semibold)) + Circle() + .fill(self.mcpStatusColor) + .frame(width: 7, height: 7) + Text(self.mcpStatusText) + .font(.caption) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(self.mcpStatusColor) + } + } + .menuIndicator(.hidden) + .buttonStyle(.plain) + .help("MCP status and settings") + + Menu { + Button(role: .destructive) { + self.showingClearConfirmation = true + } label: { + Label("Delete current session", systemImage: "trash") + } + .disabled(self.service.isProcessing) + } label: { + self.headerControlChip { + Image(systemName: "ellipsis") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + .menuIndicator(.hidden) + .buttonStyle(.plain) + .help("Session actions") } - .toggleStyle(.checkbox) - .help("Ask for confirmation before running commands") } - .padding() + .padding(.horizontal, 16) + .padding(.vertical, 12) .background(self.theme.palette.windowBackground) .confirmationDialog( - "Delete this chat?", + "Delete this session?", isPresented: self.$showingClearConfirmation, titleVisibility: .visible ) { @@ -166,6 +279,41 @@ struct CommandModeView: View { } } + private var currentSessionTitle: String { + self.service.getRecentChats() + .first(where: { $0.id == self.service.currentChatID })? + .title ?? "New Session" + } + + private var approvalModeText: String { + self.settings.commandModeConfirmBeforeExecute ? "Manual approval" : "Auto-approve" + } + + private var approvalModeIcon: String { + self.settings.commandModeConfirmBeforeExecute ? "shield" : "checkmark.shield" + } + + private var approvalModeColor: Color { + self.settings.commandModeConfirmBeforeExecute ? .orange : Color.fluidGreen + } + + private func headerControlChip<Content: View>(@ViewBuilder _ content: () -> Content) + -> some View + { + content() + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(self.theme.palette.cardBackground.opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.55), lineWidth: 1) + ) + } + // MARK: - How To Section private var shortcutDisplay: String { @@ -175,7 +323,8 @@ struct CommandModeView: View { private var howToSection: some View { VStack(spacing: 0) { // Toggle button with hover effect - Button(action: { withAnimation(.easeInOut(duration: 0.2)) { self.showHowTo.toggle() } }) { + Button(action: { withAnimation(.easeInOut(duration: 0.2)) { self.showHowTo.toggle() } }) + { HStack { Image(systemName: "questionmark.circle") .font(.caption) @@ -188,7 +337,10 @@ struct CommandModeView: View { .foregroundStyle(self.isHoveringHowTo ? .primary : .secondary) .padding(.horizontal, 16) .padding(.vertical, 8) - .background(self.isHoveringHowTo ? self.theme.palette.cardBackground.opacity(0.6) : Color.clear) + .background( + self.isHoveringHowTo + ? self.theme.palette.cardBackground.opacity(0.6) : Color.clear + ) .cornerRadius(4) } .buttonStyle(.plain) @@ -215,8 +367,10 @@ struct CommandModeView: View { .padding(.vertical, 2) .background(self.theme.palette.cardBackground.opacity(0.8)) .cornerRadius(4) - Text("to open Command Mode, speak your command, then press again to send.") - .font(.caption) + Text( + "to open Command Mode, speak your command, then press again to send." + ) + .font(.caption) } .foregroundStyle(.primary.opacity(0.8)) } @@ -246,9 +400,11 @@ struct CommandModeView: View { } .font(.caption) - Text("AI can make mistakes. Avoid dangerous commands like deleting important files. Destructive actions will ask for confirmation.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + "AI can make mistakes. Avoid dangerous commands like deleting important files. Destructive actions will ask for confirmation." + ) + .font(.caption) + .foregroundStyle(.secondary) } } .padding(.horizontal, 16) @@ -288,7 +444,9 @@ struct CommandModeView: View { // Show thinking tokens in collapsible section (real-time) // Only show if setting is enabled AND there are thinking tokens - if self.settings.showThinkingTokens && !self.service.streamingThinkingText.isEmpty { + if self.settings.showThinkingTokens + && !self.service.streamingThinkingText.isEmpty + { self.thinkingView } @@ -321,7 +479,7 @@ struct CommandModeView: View { // Scroll when processing starts, not on every streaming update if isProcessing { self.scrollToBottom(proxy) - self.isThinkingExpanded = false // Collapse thinking for new request + self.isThinkingExpanded = false // Collapse thinking for new request } } .onChange(of: self.service.currentStep) { _, _ in @@ -336,7 +494,9 @@ struct CommandModeView: View { private var thinkingView: some View { VStack(alignment: .leading, spacing: 0) { // Header with shimmer effect - tap to expand/collapse - Button(action: { withAnimation(.easeInOut(duration: 0.2)) { self.isThinkingExpanded.toggle() } }) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { self.isThinkingExpanded.toggle() } + }) { HStack(spacing: 8) { ThinkingShimmerLabel() @@ -366,12 +526,15 @@ struct CommandModeView: View { } else { // Preview - first 150 chars if !self.service.streamingThinkingText.isEmpty { - Text(String(self.service.streamingThinkingText.prefix(150)) + (self.service.streamingThinkingText.count > 150 ? "..." : "")) - .font(.system(size: 11)) - .foregroundStyle(.secondary.opacity(0.7)) - .lineLimit(2) - .padding(.horizontal, 12) - .padding(.bottom, 8) + Text( + String(self.service.streamingThinkingText.prefix(150)) + + (self.service.streamingThinkingText.count > 150 ? "..." : "") + ) + .font(.system(size: 11)) + .foregroundStyle(.secondary.opacity(0.7)) + .lineLimit(2) + .padding(.horizontal, 12) + .padding(.bottom, 8) } } } @@ -393,8 +556,8 @@ struct CommandModeView: View { // MARK: - Streaming Text View (Real-time AI response) private var streamingTextView: some View { - // Use fixedSize to prevent expensive re-layout on every update - Text(self.service.streamingText) + // Use markdown preview for streaming assistant responses + Markdown(self.service.streamingText) .font(.system(size: 13)) .foregroundStyle(.primary.opacity(0.9)) .padding(.horizontal, 12) @@ -402,7 +565,6 @@ struct CommandModeView: View { .frame(maxWidth: 520, alignment: .leading) .background(self.theme.palette.contentBackground.opacity(0.9)) .cornerRadius(8) - .drawingGroup() // Flatten to bitmap for faster updates // textSelection disabled during streaming - re-enabled in final message } @@ -410,10 +572,10 @@ struct CommandModeView: View { guard let step = service.currentStep else { return "Working..." } switch step { case .thinking: return "Thinking..." - case let .checking(cmd): return "Checking \(self.truncateCommand(cmd, to: 30))" - case let .executing(cmd): return "Running \(self.truncateCommand(cmd, to: 30))" + case .checking(let cmd): return "Checking \(self.truncateCommand(cmd, to: 30))" + case .executing(let cmd): return "Running \(self.truncateCommand(cmd, to: 30))" case .verifying: return "Verifying..." - case let .completed(success): return success ? "Done" : "Stopped" + case .completed(let success): return success ? "Done" : "Stopped" } } @@ -435,7 +597,17 @@ struct CommandModeView: View { // MARK: - Pending Command private func pendingCommandView(_ pending: CommandModeService.PendingCommand) -> some View { - VStack(spacing: 10) { + let isTerminalCommand = pending.isTerminalCommand + let previewTitle = isTerminalCommand ? "Command" : "MCP Tool" + let previewIcon = isTerminalCommand ? "terminal.fill" : "wrench.and.screwdriver.fill" + let previewContent = isTerminalCommand ? (pending.command ?? "") : pending.argumentsJSON + let runButtonTitle = isTerminalCommand ? "Run Command" : "Run Tool" + let detailText = + pending.purpose?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? pending.purpose + : (isTerminalCommand ? nil : pending.toolName) + + return VStack(spacing: 10) { Divider() HStack { @@ -445,8 +617,8 @@ struct CommandModeView: View { VStack(alignment: .leading, spacing: 2) { Text("Confirm Execution") .fontWeight(.semibold) - if let purpose = pending.purpose { - Text(purpose) + if let detailText { + Text(detailText) .font(.caption) .foregroundStyle(.secondary) } @@ -454,12 +626,12 @@ struct CommandModeView: View { Spacer() } - // Command preview + // Tool preview VStack(alignment: .leading, spacing: 0) { HStack { - Image(systemName: "terminal.fill") + Image(systemName: previewIcon) .font(.caption) - Text("Command") + Text(previewTitle) .font(.caption) .fontWeight(.medium) Spacer() @@ -470,7 +642,7 @@ struct CommandModeView: View { Divider() - Text(pending.command) + Text(previewContent) .font(.system(.callout, design: .monospaced)) .textSelection(.enabled) .padding(10) @@ -492,7 +664,7 @@ struct CommandModeView: View { Button(action: { Task { await self.service.confirmAndExecute() } }) { - Label("Run Command", systemImage: "play.fill") + Label(runButtonTitle, systemImage: "play.fill") } .buttonStyle(.borderedProminent) .tint(.orange) @@ -515,7 +687,9 @@ struct CommandModeView: View { get: { self.settings.commandModeSelectedProviderID }, set: { newValue in // Prevent selecting disabled Apple Intelligence - if newValue == "apple-intelligence-disabled" || newValue == "apple-intelligence" { + if newValue == "apple-intelligence-disabled" + || newValue == "apple-intelligence" + { self.settings.commandModeSelectedProviderID = "openai" } else { self.settings.commandModeSelectedProviderID = newValue @@ -529,7 +703,9 @@ struct CommandModeView: View { SearchableModelPicker( models: self.availableModels, selectedModel: Binding( - get: { self.settings.commandModeSelectedModel ?? self.availableModels.first ?? "" }, + get: { + self.settings.commandModeSelectedModel ?? self.availableModels.first ?? "" + }, set: { self.settings.commandModeSelectedModel = $0 } ), onRefresh: nil, @@ -616,6 +792,67 @@ struct CommandModeView: View { appleIntelligenceDisabledReason: "No tools" ) } + + private var mcpStatusText: String { + if self.isReloadingMCP { + return "MCP reloading..." + } + + if self.service.isMCPBootstrapInProgress { + return "MCP loading..." + } + + let connected = self.service.mcpConnectedServerCount + let enabled = self.service.mcpEnabledServerCount + return "MCP \(connected)/\(enabled)" + } + + private var mcpStatusColor: Color { + if self.isReloadingMCP || self.service.isMCPBootstrapInProgress { + return .secondary + } + + let connected = self.service.mcpConnectedServerCount + let enabled = self.service.mcpEnabledServerCount + + if enabled == 0 { + return .secondary + } + if connected == enabled { + return Color.fluidGreen + } + if connected > 0 { + return .orange + } + return .red + } + + private func openMCPSettingsFile() { + Task { + if let url = await self.service.mcpSettingsFileURL() { + NSWorkspace.shared.open(url) + } + } + } + + private func revealMCPSettingsFile() { + Task { + if let url = await self.service.mcpSettingsFileURL() { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + } + + private func reloadMCPConfiguration() { + guard !self.isReloadingMCP else { return } + self.isReloadingMCP = true + Task { + await self.service.reloadMCPConfiguration() + await MainActor.run { + self.isReloadingMCP = false + } + } + } } // MARK: - Shimmer Effect (Cursor-style) @@ -673,9 +910,13 @@ struct ThinkingShimmerLabel: View { LinearGradient( stops: [ .init(color: Color.primary.opacity(0.35), location: 0), - .init(color: Color.primary.opacity(0.35), location: max(0, self.shimmerPhase - 0.15)), + .init( + color: Color.primary.opacity(0.35), + location: max(0, self.shimmerPhase - 0.15)), .init(color: Color.primary.opacity(0.85), location: self.shimmerPhase), - .init(color: Color.primary.opacity(0.35), location: min(1, self.shimmerPhase + 0.15)), + .init( + color: Color.primary.opacity(0.35), + location: min(1, self.shimmerPhase + 0.15)), .init(color: Color.primary.opacity(0.35), location: 1), ], startPoint: .leading, @@ -743,24 +984,27 @@ struct MessageBubble: View { private var agentMessageView: some View { VStack(alignment: .leading, spacing: 6) { // Thinking section (collapsible) - only if setting is enabled - if let thinking = message.thinking, !thinking.isEmpty, SettingsStore.shared.showThinkingTokens { + if let thinking = message.thinking, !thinking.isEmpty, + SettingsStore.shared.showThinkingTokens + { self.thinkingSection(thinking) } - // Purpose label (minimal, gray) - if let tc = message.toolCall, let purpose = tc.purpose { - Text(purpose) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - - // Main content - if self.message.role == .tool { + switch self.message.renderIntent { + case .status: + self.statusContentView + case .toolInvocation: + if let tc = message.toolCall { + self.commandCallView(tc) + } else if !self.message.content.isEmpty { + self.textContentView + } + case .toolResult: self.toolOutputView - } else if let tc = message.toolCall { - self.commandCallView(tc) - } else if !self.message.content.isEmpty { - self.textContentView + case .assistantText, .userText: + if !self.message.content.isEmpty { + self.textContentView + } } } .frame(maxWidth: 520, alignment: .leading) @@ -771,7 +1015,9 @@ struct MessageBubble: View { private func thinkingSection(_ thinking: String) -> some View { VStack(alignment: .leading, spacing: 0) { // Header - tap to expand/collapse - Button(action: { withAnimation(.easeInOut(duration: 0.2)) { self.isThinkingExpanded.toggle() } }) { + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { self.isThinkingExpanded.toggle() } + }) { HStack(spacing: 6) { HStack(spacing: 2) { ForEach(0..<3, id: \.self) { _ in @@ -829,19 +1075,41 @@ struct MessageBubble: View { private func commandCallView(_ tc: CommandModeService.Message.ToolCall) -> some View { VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(self.operationLabel(for: self.message.stepType, toolCall: tc)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + + Spacer() + + if let shortID = self.shortCallID(tc.id) { + Text(shortID) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + } + + if let purpose = tc.purpose, + !purpose.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + Text(purpose) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + // Reasoning text (if meaningful) - if !self.message.content.isEmpty && - !self.message.content.lowercased().starts(with: "checking") && - !self.message.content.lowercased().starts(with: "executing") && - !self.message.content.lowercased().starts(with: "i'll") + if !self.message.content.isEmpty + && !self.message.content.lowercased().starts(with: "checking") + && !self.message.content.lowercased().starts(with: "executing") + && !self.message.content.lowercased().starts(with: "i'll") { - Text(self.message.content) + Markdown(self.message.content) .font(.system(size: 12)) .foregroundStyle(.secondary) } // Command block - clean and simple - Text(tc.command) + Text(self.toolCallDisplayText(tc)) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(.primary) .textSelection(.enabled) @@ -852,6 +1120,18 @@ struct MessageBubble: View { } } + private var statusContentView: some View { + HStack(spacing: 6) { + Circle() + .fill(Color.secondary.opacity(0.6)) + .frame(width: 5, height: 5) + Text(self.message.content) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + // MARK: - Tool Output View (Minimal) private var toolOutputView: some View { @@ -864,6 +1144,12 @@ struct MessageBubble: View { .font(.system(size: 11, weight: .medium)) .foregroundStyle(parsed.success ? .primary : .secondary) + if let shortID = self.shortCallID(self.message.sourceToolCallID) { + Text(shortID) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + Spacer() if parsed.executionTime > 0 { @@ -883,7 +1169,7 @@ struct MessageBubble: View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 2) { if !parsed.output.isEmpty { - Text(self.markdownAttributedString(from: parsed.output)) + Text(parsed.output) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) @@ -910,26 +1196,11 @@ struct MessageBubble: View { // MARK: - Text Content View (Minimal) private var textContentView: some View { - Text(self.markdownAttributedString(from: self.message.content)) + Markdown(self.message.content) .font(.system(size: 13)) .textSelection(.enabled) } - // MARK: - Markdown Rendering - - private func markdownAttributedString(from text: String) -> AttributedString { - do { - let attributed = try AttributedString( - markdown: text, - options: AttributedString - .MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - return attributed - } catch { - return AttributedString(text) - } - } - // MARK: - Helpers private struct ParsedOutput { @@ -942,9 +1213,10 @@ struct MessageBubble: View { private func parseToolOutput(_ json: String) -> ParsedOutput { guard let data = json.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return ParsedOutput(success: false, output: json, error: nil, exitCode: -1, executionTime: 0) + return ParsedOutput( + success: false, output: json, error: nil, exitCode: -1, executionTime: 0) } return ParsedOutput( @@ -955,4 +1227,42 @@ struct MessageBubble: View { executionTime: parsed["executionTimeMs"] as? Int ?? 0 ) } + + private func operationLabel( + for stepType: CommandModeService.Message.StepType, + toolCall: CommandModeService.Message.ToolCall + ) -> String { + if !toolCall.isTerminalCommand { + return "Calling MCP tool" + } + + switch stepType { + case .checking: + return "Checking" + case .verifying: + return "Verifying" + case .executing: + return "Executing" + default: + return "Executing" + } + } + + private func shortCallID(_ callID: String?) -> String? { + guard let callID else { return nil } + let trimmed = callID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= 12 { + return trimmed + } + return String(trimmed.suffix(12)) + } + + private func toolCallDisplayText(_ tc: CommandModeService.Message.ToolCall) -> String { + if let command = tc.command, !command.isEmpty { + return command + } + let compactArgs = tc.argumentsJSON.replacingOccurrences(of: "\n", with: "") + return "\(tc.toolName)(\(compactArgs))" + } } diff --git a/Sources/Fluid/Views/MCPSettingsEditorView.swift b/Sources/Fluid/Views/MCPSettingsEditorView.swift new file mode 100644 index 00000000..55487feb --- /dev/null +++ b/Sources/Fluid/Views/MCPSettingsEditorView.swift @@ -0,0 +1,272 @@ +import AppKit +import SwiftUI + +struct MCPSettingsEditorView: View { + @ObservedObject var service: CommandModeService + + @Environment(\.dismiss) private var dismiss + @Environment(\.theme) private var theme + + @State private var originalText: String = "" + @State private var draftText: String = "" + @State private var settingsPath: String = "" + @State private var statusMessage: String = "Loading settings.json..." + @State private var errorMessage: String? + @State private var isLoading = true + @State private var isReloadingFromDisk = false + @State private var isValidating = false + @State private var isSaving = false + @State private var showingDiscardConfirmation = false + + private var hasUnsavedChanges: Bool { + self.draftText != self.originalText + } + + private var isBusy: Bool { + self.isLoading || self.isReloadingFromDisk || self.isValidating || self.isSaving + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Text("Edit MCP settings.json") + .font(.headline) + + if self.hasUnsavedChanges { + Text("Unsaved") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(Color.orange.opacity(0.2))) + .foregroundStyle(.orange) + } + + Spacer() + } + + Text(self.settingsPath.isEmpty ? "Path unavailable" : self.settingsPath) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(2) + + Text("Save validates JSON and auto-reloads MCP servers.") + .font(.caption) + .foregroundStyle(.secondary) + + self.feedbackBanner + + Group { + if self.isLoading { + VStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Loading settings.json...") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + PromptTextView( + text: self.$draftText, + isEditable: !self.isBusy, + font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular), + contentInset: 12 + ) + .frame(minHeight: 320) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.8)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.45), lineWidth: 1) + ) + ) + } + } + + HStack(spacing: 10) { + Button("Cancel") { + self.handleDismissRequest() + } + .buttonStyle(.bordered) + .keyboardShortcut(.escape, modifiers: []) + + Spacer() + + Button(self.isReloadingFromDisk ? "Reloading..." : "Reload From Disk") { + Task { await self.reloadFromDisk() } + } + .buttonStyle(.bordered) + .disabled(self.isBusy) + + Button(self.isValidating ? "Validating..." : "Validate") { + Task { await self.validateDraft() } + } + .buttonStyle(.bordered) + .disabled(self.isBusy) + + Button(self.isSaving ? "Saving..." : "Save & Reload MCP") { + Task { await self.saveDraft() } + } + .buttonStyle(.borderedProminent) + .disabled(self.isBusy || !self.hasUnsavedChanges) + .keyboardShortcut(.return, modifiers: []) + } + } + .padding(16) + .frame(minWidth: 760, minHeight: 560) + .background(self.theme.palette.windowBackground) + .task { + await self.loadInitialState() + } + .interactiveDismissDisabled(self.hasUnsavedChanges || self.isBusy) + .confirmationDialog( + "Discard unsaved MCP changes?", + isPresented: self.$showingDiscardConfirmation, + titleVisibility: .visible + ) { + Button("Discard Changes", role: .destructive) { + self.dismiss() + } + Button("Continue Editing", role: .cancel) {} + } + } + + @ViewBuilder + private var feedbackBanner: some View { + if let errorMessage, !errorMessage.isEmpty { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.red.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.red.opacity(0.3), lineWidth: 1) + ) + ) + } else { + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(self.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(self.theme.palette.contentBackground.opacity(0.6)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(self.theme.palette.cardBorder.opacity(0.35), lineWidth: 1) + ) + ) + } + } + + private func handleDismissRequest() { + if self.hasUnsavedChanges { + self.showingDiscardConfirmation = true + } else { + self.dismiss() + } + } + + @MainActor + private func loadInitialState() async { + guard self.isLoading else { return } + self.errorMessage = nil + self.statusMessage = "Loading settings.json..." + + do { + let json = try await self.service.loadMCPSettingsJSON() + let fileURL = await self.service.mcpSettingsFileURL() + + self.settingsPath = fileURL?.path ?? "" + self.draftText = json + self.originalText = json + self.statusMessage = "Loaded settings.json." + } catch { + self.errorMessage = error.localizedDescription + self.statusMessage = "Could not load settings.json." + } + + self.isLoading = false + } + + @MainActor + private func reloadFromDisk() async { + guard !self.isBusy else { return } + + self.isReloadingFromDisk = true + self.errorMessage = nil + self.statusMessage = "Reloading settings.json from disk..." + + do { + let json = try await self.service.loadMCPSettingsJSON() + let fileURL = await self.service.mcpSettingsFileURL() + + if let fileURL { + self.settingsPath = fileURL.path + } + self.draftText = json + self.originalText = json + self.statusMessage = "Reloaded settings.json from disk." + } catch { + self.errorMessage = error.localizedDescription + self.statusMessage = "Reload failed." + } + + self.isReloadingFromDisk = false + } + + @MainActor + private func validateDraft() async { + guard !self.isBusy else { return } + + self.isValidating = true + self.errorMessage = nil + self.statusMessage = "Validating settings.json..." + + do { + try await self.service.validateMCPSettingsJSON(self.draftText) + self.statusMessage = "Validation passed." + } catch { + self.errorMessage = error.localizedDescription + self.statusMessage = "Validation failed." + } + + self.isValidating = false + } + + @MainActor + private func saveDraft() async { + guard !self.isBusy else { return } + + self.isSaving = true + self.errorMessage = nil + self.statusMessage = "Saving settings.json and reloading MCP..." + + do { + try await self.service.saveMCPSettingsJSONAndReload(self.draftText) + self.originalText = self.draftText + self.statusMessage = "Saved settings.json and reloaded MCP." + } catch { + self.errorMessage = error.localizedDescription + self.statusMessage = "Save failed." + } + + self.isSaving = false + } +} diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index 713735b9..f291e29d 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -7,6 +7,7 @@ import AppKit import Combine +import MarkdownUI import QuartzCore import SwiftUI @@ -48,6 +49,35 @@ class NotchContentState: ObservableObject { @Published var commandInputText: String = "" // User's follow-up input @Published var commandConversationHistory: [CommandOutputMessage] = [] @Published var isCommandProcessing: Bool = false + @Published var commandTurnBadgeState: CommandTurnBadgeState = .hidden + + enum CommandTurnBadgeState: Equatable { + case hidden + case success + case failure + + var symbolName: String { + switch self { + case .hidden: + return "" + case .success: + return "checkmark.circle.fill" + case .failure: + return "xmark.circle.fill" + } + } + + var color: Color { + switch self { + case .hidden: + return .clear + case .success: + return Color.fluidGreen + case .failure: + return Color(red: 1.0, green: 0.35, blue: 0.35) + } + } + } // MARK: - Chat History State @@ -208,6 +238,16 @@ class NotchContentState: ObservableObject { self.isCommandProcessing = processing } + func setCommandTurnBadge(success: Bool) { + self.commandTurnBadgeState = success ? .success : .failure + } + + func clearCommandTurnBadge() { + if self.commandTurnBadgeState != .hidden { + self.commandTurnBadgeState = .hidden + } + } + /// Clear command output and hide expanded view func clearCommandOutput() { self.isExpandedForCommandOutput = false @@ -216,6 +256,7 @@ class NotchContentState: ObservableObject { self.commandInputText = "" self.commandConversationHistory.removeAll() self.isCommandProcessing = false + self.clearCommandTurnBadge() } /// Hide expanded view but keep history @@ -1047,9 +1088,22 @@ struct NotchCompactLeadingView: View { @ObservedObject private var contentState = NotchContentState.shared @ObservedObject private var activeAppMonitor = ActiveAppMonitor.shared + private var isShowingCommandTurnBadge: Bool { + self.contentState.commandTurnBadgeState != .hidden + } + var body: some View { Group { - if let appIcon = self.contentState.targetAppIcon ?? self.activeAppMonitor.activeAppIcon { + if self.isShowingCommandTurnBadge { + Image(systemName: self.contentState.commandTurnBadgeState.symbolName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(self.contentState.commandTurnBadgeState.color) + .shadow(color: self.contentState.commandTurnBadgeState.color.opacity(0.35), radius: 4, x: 0, y: 0) + .contentShape(Rectangle()) + .onTapGesture { + NotchOverlayManager.shared.onNotchClicked?() + } + } else if let appIcon = self.contentState.targetAppIcon ?? self.activeAppMonitor.activeAppIcon { Image(nsImage: appIcon) .resizable() .aspectRatio(contentMode: .fit) @@ -1068,12 +1122,29 @@ struct NotchCompactTrailingView: View { let audioPublisher: AnyPublisher<CGFloat, Never> @ObservedObject private var contentState = NotchContentState.shared + private var isShowingCommandTurnBadge: Bool { + self.contentState.commandTurnBadgeState != .hidden + } + var body: some View { - CompactNotchWaveformView( - audioPublisher: self.audioPublisher, - color: self.contentState.mode.notchColor - ) - .frame(width: 34, height: 16) + Group { + if self.isShowingCommandTurnBadge { + Circle() + .fill(self.contentState.commandTurnBadgeState.color) + .frame(width: 6, height: 6) + .shadow(color: self.contentState.commandTurnBadgeState.color.opacity(0.35), radius: 3, x: 0, y: 0) + .contentShape(Rectangle()) + .onTapGesture { + NotchOverlayManager.shared.onNotchClicked?() + } + } else { + CompactNotchWaveformView( + audioPublisher: self.audioPublisher, + color: self.contentState.mode.notchColor + ) + .frame(width: 34, height: 16) + } + } } } @@ -1471,15 +1542,7 @@ struct NotchCommandOutputExpandedView: View { .textSelection(.enabled) case .assistant: - Text(message.content) - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.85)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.white.opacity(0.08)) - .cornerRadius(8) - .frame(maxWidth: 320, alignment: .leading) - .textSelection(.enabled) + self.assistantMarkdownBubble(message.content, isStreaming: false) Spacer() case .status: @@ -1499,20 +1562,36 @@ struct NotchCommandOutputExpandedView: View { private var streamingMessageView: some View { HStack(alignment: .top) { - Text(self.contentState.commandStreamingText) - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.85)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.white.opacity(0.08)) - .cornerRadius(8) - .frame(maxWidth: 320, alignment: .leading) + self.assistantMarkdownBubble(self.contentState.commandStreamingText, isStreaming: true) .drawingGroup() // Flatten to bitmap for faster streaming updates // textSelection disabled during streaming for performance Spacer() } } + @ViewBuilder + private func assistantMarkdownBubble(_ content: String, isStreaming: Bool) -> some View { + Group { + if isStreaming { + Markdown(content) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.85)) + .tint(self.commandRed) + } else { + Markdown(content) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.85)) + .tint(self.commandRed) + .textSelection(.enabled) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.08)) + .cornerRadius(8) + .frame(maxWidth: 320, alignment: .leading) + } + private var processingIndicator: some View { HStack(spacing: 6) { ForEach(0..<3, id: \.self) { index in diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 131f08f9..447dcbc6 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -1,4 +1,5 @@ import Foundation +import UserNotifications import XCTest @testable import FluidVoice_Debug @@ -333,3 +334,835 @@ final class DictationE2ETests: XCTestCase { ) } } + +private class MockLLMURLProtocol: URLProtocol { + struct MockResponse { + let statusCode: Int + let headers: [String: String] + let body: Data + + init(statusCode: Int, headers: [String: String] = [:], body: Data) { + self.statusCode = statusCode + self.headers = headers + self.body = body + } + } + + private static let queue = DispatchQueue(label: "MockLLMURLProtocol.queue") + private static var requestHandler: ((URLRequest, Int) throws -> MockResponse)? + private static var recordedRequests: [URLRequest] = [] + + static func configure(handler: @escaping (URLRequest, Int) throws -> MockResponse) { + self.queue.sync { + self.recordedRequests = [] + self.requestHandler = handler + } + } + + static func reset() { + self.queue.sync { + self.recordedRequests = [] + self.requestHandler = nil + } + } + + static var requests: [URLRequest] { + self.queue.sync { + self.recordedRequests + } + } + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + let (handler, requestIndex) = Self.queue.sync { () -> (((URLRequest, Int) throws -> MockResponse)?, Int) in + let index = Self.recordedRequests.count + Self.recordedRequests.append(self.request) + return (Self.requestHandler, index) + } + + guard let handler else { + self.client?.urlProtocol( + self, + didFailWithError: NSError(domain: "MockLLMURLProtocol", code: -1, userInfo: [NSLocalizedDescriptionKey: "No request handler configured"]) + ) + return + } + + do { + let mock = try handler(self.request, requestIndex) + guard let url = self.request.url, + let response = HTTPURLResponse(url: url, statusCode: mock.statusCode, httpVersion: nil, headerFields: mock.headers) + else { + self.client?.urlProtocol( + self, + didFailWithError: NSError(domain: "MockLLMURLProtocol", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to build HTTPURLResponse"]) + ) + return + } + + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if !mock.body.isEmpty { + self.client?.urlProtocol(self, didLoad: mock.body) + } + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +@MainActor +final class LLMClientRoutingTests: XCTestCase { + override func tearDown() { + MockLLMURLProtocol.reset() + super.tearDown() + } + + func testAnthropicProvider_routesToMessagesWithAnthropicHeaders() async throws { + MockLLMURLProtocol.configure { _, _ in + let payload: [String: Any] = [ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [[ + "type": "text", + "text": "Anthropic ok", + ]], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + return MockLLMURLProtocol.MockResponse(statusCode: 200, body: data) + } + + let client = self.makeClient() + let config = LLMClient.Config( + messages: [["role": "user", "content": "Hello"]], + providerID: "anthropic", + model: "claude-3-5-sonnet-latest", + baseURL: "https://api.anthropic.com/v1", + apiKey: "anthropic-test-key", + streaming: false + ) + + let response = try await client.call(config) + XCTAssertEqual(response.content, "Anthropic ok") + + let requests = MockLLMURLProtocol.requests + XCTAssertEqual(requests.count, 1) + + guard let request = requests.first else { + XCTFail("Expected one captured request") + return + } + + XCTAssertEqual(request.url?.absoluteString, "https://api.anthropic.com/v1/messages") + XCTAssertEqual(request.value(forHTTPHeaderField: "x-api-key"), "anthropic-test-key") + XCTAssertEqual(request.value(forHTTPHeaderField: "anthropic-version"), "2023-06-01") + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + + func testOpenAICompatibleProvider_fallsBackFromResponsesToChatCompletions() async throws { + MockLLMURLProtocol.configure { _, requestIndex in + if requestIndex == 0 { + let body = Data("{\"error\":\"responses endpoint not supported\"}".utf8) + return MockLLMURLProtocol.MockResponse(statusCode: 404, body: body) + } + + let payload: [String: Any] = [ + "choices": [[ + "message": [ + "role": "assistant", + "content": "Fallback response", + ], + ]], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + return MockLLMURLProtocol.MockResponse(statusCode: 200, body: data) + } + + let client = self.makeClient() + let config = LLMClient.Config( + messages: [["role": "user", "content": "Hello"]], + providerID: "openai", + model: "gpt-4o-mini", + baseURL: "https://api.openai.com/v1", + apiKey: "openai-test-key", + streaming: false + ) + + let response = try await client.call(config) + XCTAssertEqual(response.content, "Fallback response") + + let requests = MockLLMURLProtocol.requests + XCTAssertEqual(requests.count, 2) + + guard requests.count == 2 else { + XCTFail("Expected responses request then chat completions fallback") + return + } + + XCTAssertEqual(requests[0].url?.path, "/v1/responses") + XCTAssertEqual(requests[1].url?.path, "/v1/chat/completions") + XCTAssertEqual(requests[0].value(forHTTPHeaderField: "Authorization"), "Bearer openai-test-key") + XCTAssertEqual(requests[1].value(forHTTPHeaderField: "Authorization"), "Bearer openai-test-key") + + let firstBody = try self.decodeJSONBody(from: requests[0]) + XCTAssertNotNil(firstBody["input"]) + XCTAssertNil(firstBody["messages"]) + + let secondBody = try self.decodeJSONBody(from: requests[1]) + XCTAssertNotNil(secondBody["messages"]) + } + + func testOpenAICompatibleChatEndpoint_fallsBackUsingSameChatPath() async throws { + MockLLMURLProtocol.configure { _, requestIndex in + if requestIndex == 0 { + let body = Data("{\"error\":\"responses endpoint not supported\"}".utf8) + return MockLLMURLProtocol.MockResponse(statusCode: 404, body: body) + } + + let payload: [String: Any] = [ + "choices": [[ + "message": [ + "role": "assistant", + "content": "Fallback response", + ], + ]], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + return MockLLMURLProtocol.MockResponse(statusCode: 200, body: data) + } + + let client = self.makeClient() + let config = LLMClient.Config( + messages: [["role": "user", "content": "Hello"]], + providerID: "openai", + model: "gpt-4o-mini", + baseURL: "https://example.com/api/chat", + apiKey: "openai-test-key", + streaming: false + ) + + let response = try await client.call(config) + XCTAssertEqual(response.content, "Fallback response") + + let requests = MockLLMURLProtocol.requests + XCTAssertEqual(requests.count, 2) + + guard requests.count == 2 else { + XCTFail("Expected responses payload attempt then chat completions fallback on the same endpoint") + return + } + + XCTAssertEqual(requests[0].url?.path, "/api/chat") + XCTAssertEqual(requests[1].url?.path, "/api/chat") + + let firstBody = try self.decodeJSONBody(from: requests[0]) + XCTAssertNotNil(firstBody["input"]) + XCTAssertNil(firstBody["messages"]) + + let secondBody = try self.decodeJSONBody(from: requests[1]) + XCTAssertNotNil(secondBody["messages"]) + XCTAssertNil(secondBody["input"]) + } + + func testOpenAICompatibleFallback_normalizesResponsesStyleToolsForChatCompletions() async throws { + MockLLMURLProtocol.configure { _, requestIndex in + if requestIndex == 0 { + let body = Data("{\"error\":\"responses endpoint not supported\"}".utf8) + return MockLLMURLProtocol.MockResponse(statusCode: 404, body: body) + } + + let payload: [String: Any] = [ + "choices": [[ + "message": [ + "role": "assistant", + "content": "Fallback response", + ], + ]], + ] + + let data = try JSONSerialization.data(withJSONObject: payload) + return MockLLMURLProtocol.MockResponse(statusCode: 200, body: data) + } + + let client = self.makeClient() + let tools: [[String: Any]] = [ + [ + "type": "function", + "name": "execute_terminal_command", + "description": "Run a shell command", + "parameters": [ + "type": "object", + "properties": [ + "command": ["type": "string"], + ], + "required": ["command"], + ], + ], + ] + let config = LLMClient.Config( + messages: [["role": "user", "content": "Hello"]], + providerID: "openai", + model: "gpt-4o-mini", + baseURL: "https://example.com/api/chat", + apiKey: "openai-test-key", + streaming: false, + tools: tools + ) + + _ = try await client.call(config) + + let requests = MockLLMURLProtocol.requests + XCTAssertEqual(requests.count, 2) + + guard requests.count == 2 else { + XCTFail("Expected fallback request sequence") + return + } + + let firstBody = try self.decodeJSONBody(from: requests[0]) + guard let responsesTools = firstBody["tools"] as? [[String: Any]], + let responsesTool = responsesTools.first + else { + XCTFail("Expected responses tools in first request") + return + } + + XCTAssertEqual(responsesTool["name"] as? String, "execute_terminal_command") + XCTAssertNil(responsesTool["function"]) + + let secondBody = try self.decodeJSONBody(from: requests[1]) + guard let chatTools = secondBody["tools"] as? [[String: Any]], + let chatTool = chatTools.first, + let function = chatTool["function"] as? [String: Any] + else { + XCTFail("Expected chat-completions tools in fallback request") + return + } + + XCTAssertEqual(chatTool["type"] as? String, "function") + XCTAssertEqual(function["name"] as? String, "execute_terminal_command") + XCTAssertEqual(function["description"] as? String, "Run a shell command") + XCTAssertNotNil(function["parameters"]) + XCTAssertNil(chatTool["name"]) + } + + func testAnthropicPayload_normalizesToolCallsAndFiltersOpenAIOnlyExtras() async throws { + MockLLMURLProtocol.configure { _, _ in + let payload: [String: Any] = [ + "type": "message", + "content": [[ + "type": "text", + "text": "Done", + ]], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + return MockLLMURLProtocol.MockResponse(statusCode: 200, body: data) + } + + let client = self.makeClient() + + let tools: [[String: Any]] = [ + [ + "type": "function", + "function": [ + "name": "get_weather", + "description": "Get weather by city", + "parameters": [ + "type": "object", + "properties": [ + "city": ["type": "string"], + ], + "required": ["city"], + ], + ], + ], + ] + + let messages: [[String: Any]] = [ + ["role": "system", "content": "System behavior"], + ["role": "user", "content": "Find weather"], + [ + "role": "assistant", + "content": "Checking weather", + "tool_calls": [ + [ + "id": "call_weather", + "type": "function", + "function": [ + "name": "get_weather", + "arguments": "{\"city\":\"Paris\"}", + ], + ], + ], + ], + [ + "role": "tool", + "tool_call_id": "call_weather", + "content": "{\"temp_c\":21}", + ], + ] + + let config = LLMClient.Config( + messages: messages, + providerID: "anthropic", + model: "claude-3-5-sonnet-latest", + baseURL: "https://api.anthropic.com/v1", + apiKey: "anthropic-test-key", + streaming: false, + tools: tools, + temperature: 0.2, + maxTokens: 512, + extraParameters: [ + "reasoning_effort": "high", + "enable_thinking": true, + "metadata": ["source": "tests"], + ] + ) + + _ = try await client.call(config) + + guard let request = MockLLMURLProtocol.requests.first else { + XCTFail("Expected one captured request") + return + } + + let body = try self.decodeJSONBody(from: request) + XCTAssertEqual(body["system"] as? String, "System behavior") + XCTAssertEqual(body["max_tokens"] as? Int, 512) + XCTAssertNil(body["reasoning_effort"]) + XCTAssertNil(body["enable_thinking"]) + + let metadata = body["metadata"] as? [String: String] + XCTAssertEqual(metadata?["source"], "tests") + + guard let bodyTools = body["tools"] as? [[String: Any]], + let firstTool = bodyTools.first + else { + XCTFail("Expected normalized anthropic tool payload") + return + } + + XCTAssertEqual(firstTool["name"] as? String, "get_weather") + let inputSchema = firstTool["input_schema"] as? [String: Any] + XCTAssertEqual(inputSchema?["type"] as? String, "object") + + guard let bodyMessages = body["messages"] as? [[String: Any]] else { + XCTFail("Expected anthropic messages array") + return + } + + let assistantMessage = bodyMessages.first { ($0["role"] as? String) == "assistant" } + let assistantBlocks = assistantMessage?["content"] as? [[String: Any]] + let toolUseBlock = assistantBlocks?.first { ($0["type"] as? String) == "tool_use" } + XCTAssertEqual(toolUseBlock?["id"] as? String, "call_weather") + XCTAssertEqual(toolUseBlock?["name"] as? String, "get_weather") + let toolUseInput = toolUseBlock?["input"] as? [String: Any] + XCTAssertEqual(toolUseInput?["city"] as? String, "Paris") + + let toolResultMessage = bodyMessages.first { message in + guard let blocks = message["content"] as? [[String: Any]] else { return false } + return blocks.contains { ($0["type"] as? String) == "tool_result" } + } + let toolResultBlocks = toolResultMessage?["content"] as? [[String: Any]] + let toolResult = toolResultBlocks?.first { ($0["type"] as? String) == "tool_result" } + XCTAssertEqual(toolResult?["tool_use_id"] as? String, "call_weather") + } + + func testResponsesStreaming_mergesFunctionArgumentsByItemIDWhenCallIDDiffers() async throws { + MockLLMURLProtocol.configure { _, _ in + let events: [[String: Any]] = [ + [ + "type": "response.output_item.added", + "output_index": 0, + "item": [ + "type": "function_call", + "id": "fc_123", + "call_id": "call_123", + "name": "execute_terminal_command", + ], + ], + [ + "type": "response.function_call_arguments.delta", + "output_index": 0, + "item_id": "fc_123", + "delta": "{\"command\":\"pwd\"", + ], + [ + "type": "response.function_call_arguments.done", + "output_index": 0, + "item_id": "fc_123", + "arguments": "{\"command\":\"pwd\"}", + ], + [ + "type": "response.output_item.done", + "output_index": 0, + "item": [ + "type": "function_call", + "id": "fc_123", + "call_id": "call_123", + "name": "execute_terminal_command", + ], + ], + ] + + var streamText = "" + for event in events { + let eventData = try JSONSerialization.data(withJSONObject: event) + guard let eventLine = String(bytes: eventData, encoding: .utf8) else { + throw NSError( + domain: "MockLLMURLProtocol", + code: -5, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode mock SSE event"] + ) + } + streamText += "data: \(eventLine)\n\n" + } + streamText += "data: [DONE]\n\n" + + return MockLLMURLProtocol.MockResponse( + statusCode: 200, + headers: ["Content-Type": "text/event-stream"], + body: Data(streamText.utf8) + ) + } + + let client = self.makeClient() + let config = LLMClient.Config( + messages: [["role": "user", "content": "Run pwd"]], + providerID: "openai", + model: "gpt-4o-mini", + baseURL: "https://api.openai.com/v1", + apiKey: "openai-test-key", + streaming: true, + tools: [TerminalService.toolDefinition] + ) + + let response = try await client.call(config) + + XCTAssertEqual(response.toolCalls.count, 1) + XCTAssertEqual(response.toolCalls.first?.id, "call_123") + XCTAssertEqual(response.toolCalls.first?.name, "execute_terminal_command") + XCTAssertEqual(response.toolCalls.first?.arguments["command"] as? String, "pwd") + } + + func testAnthropicStreaming_parsesThinkingTextAndToolArguments() async throws { + MockLLMURLProtocol.configure { _, _ in + let events: [[String: Any]] = [ + [ + "type": "content_block_delta", + "index": 0, + "delta": [ + "type": "thinking_delta", + "thinking": "Plan first.", + ], + ], + [ + "type": "content_block_start", + "index": 1, + "content_block": [ + "type": "tool_use", + "id": "toolu_1", + "name": "lookup", + ], + ], + [ + "type": "content_block_delta", + "index": 1, + "delta": [ + "type": "input_json_delta", + "partial_json": "{\"city\":\"Paris\"}", + ], + ], + [ + "type": "content_block_delta", + "index": 2, + "delta": [ + "type": "text_delta", + "text": "It is sunny.", + ], + ], + ] + + var streamText = "" + for event in events { + let eventData = try JSONSerialization.data(withJSONObject: event) + guard let eventLine = String(bytes: eventData, encoding: .utf8) else { + throw NSError( + domain: "MockLLMURLProtocol", + code: -3, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode mock SSE event"] + ) + } + streamText += "data: \(eventLine)\n\n" + } + streamText += "data: [DONE]\n\n" + + return MockLLMURLProtocol.MockResponse( + statusCode: 200, + headers: ["Content-Type": "text/event-stream"], + body: Data(streamText.utf8) + ) + } + + let client = self.makeClient() + let config = LLMClient.Config( + messages: [["role": "user", "content": "Weather in Paris?"]], + providerID: "anthropic", + model: "claude-3-5-sonnet-latest", + baseURL: "https://api.anthropic.com/v1", + apiKey: "anthropic-test-key", + streaming: true + ) + + let response = try await client.call(config) + + XCTAssertEqual(response.thinking, "Plan first.") + XCTAssertEqual(response.content, "It is sunny.") + XCTAssertEqual(response.toolCalls.count, 1) + XCTAssertEqual(response.toolCalls.first?.id, "toolu_1") + XCTAssertEqual(response.toolCalls.first?.name, "lookup") + XCTAssertEqual(response.toolCalls.first?.arguments["city"] as? String, "Paris") + } + + func testAnthropicStreaming_parsesToolArgumentsWhenStartBlockContainsEmptyInput() async throws { + MockLLMURLProtocol.configure { _, _ in + let events: [[String: Any]] = [ + [ + "type": "content_block_start", + "index": 0, + "content_block": [ + "type": "tool_use", + "id": "toolu_2", + "name": "lookup", + "input": [:], + ], + ], + [ + "type": "content_block_delta", + "index": 0, + "delta": [ + "type": "input_json_delta", + "partial_json": "{\"city\":\"Berlin\"}", + ], + ], + [ + "type": "content_block_delta", + "index": 1, + "delta": [ + "type": "text_delta", + "text": "Done.", + ], + ], + ] + + var streamText = "" + for event in events { + let eventData = try JSONSerialization.data(withJSONObject: event) + guard let eventLine = String(bytes: eventData, encoding: .utf8) else { + throw NSError( + domain: "MockLLMURLProtocol", + code: -4, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode mock SSE event"] + ) + } + streamText += "data: \(eventLine)\n\n" + } + streamText += "data: [DONE]\n\n" + + return MockLLMURLProtocol.MockResponse( + statusCode: 200, + headers: ["Content-Type": "text/event-stream"], + body: Data(streamText.utf8) + ) + } + + let client = self.makeClient() + let config = LLMClient.Config( + messages: [["role": "user", "content": "Weather in Berlin?"]], + providerID: "anthropic", + model: "claude-3-5-sonnet-latest", + baseURL: "https://api.anthropic.com/v1", + apiKey: "anthropic-test-key", + streaming: true + ) + + let response = try await client.call(config) + + XCTAssertEqual(response.content, "Done.") + XCTAssertEqual(response.toolCalls.count, 1) + XCTAssertEqual(response.toolCalls.first?.id, "toolu_2") + XCTAssertEqual(response.toolCalls.first?.arguments["city"] as? String, "Berlin") + } + + private func makeClient() -> LLMClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockLLMURLProtocol.self] + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 5 + let session = URLSession(configuration: configuration) + return LLMClient(session: session) + } + + private func decodeJSONBody(from request: URLRequest) throws -> [String: Any] { + let body = try self.extractBodyData(from: request) + guard let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] else { + XCTFail("Expected JSON dictionary body") + return [:] + } + return json + } + + private func extractBodyData(from request: URLRequest) throws -> Data { + if let body = request.httpBody { + return body + } + + guard let stream = request.httpBodyStream else { + XCTFail("Expected HTTP body") + return Data() + } + + stream.open() + defer { stream.close() } + + let bufferSize = 4096 + var data = Data() + let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let bytesRead = stream.read(buffer, maxLength: bufferSize) + if bytesRead < 0 { + throw stream.streamError ?? NSError( + domain: "LLMClientRoutingTests", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed reading request body stream"] + ) + } + if bytesRead == 0 { + break + } + data.append(buffer, count: bytesRead) + } + + if data.isEmpty { + XCTFail("Expected HTTP body") + } + return data + } +} + +@MainActor +final class MCPManagerNamingTests: XCTestCase { + func testMakeUniqueSanitizedToolName_preservesSuffixWhenBaseHitsMaxLength() { + let base = String(repeating: "a", count: 64) + var usedToolNames: Set<String> = [base] + + let unique = MCPManager.makeUniqueSanitizedToolName(base: base, usedToolNames: &usedToolNames) + + XCTAssertEqual(unique, String(repeating: "a", count: 62) + "_2") + XCTAssertEqual(unique.count, 64) + } + + func testMakeUniqueSanitizedToolName_incrementsSuffixAcrossRepeatedCollisions() { + let base = String(repeating: "b", count: 64) + var usedToolNames: Set<String> = [ + base, + String(repeating: "b", count: 62) + "_2", + String(repeating: "b", count: 62) + "_3", + ] + + let unique = MCPManager.makeUniqueSanitizedToolName(base: base, usedToolNames: &usedToolNames) + + XCTAssertEqual(unique, String(repeating: "b", count: 62) + "_4") + XCTAssertEqual(unique.count, 64) + } +} + +@MainActor +final class CommandOverlayFeedbackRoutingTests: XCTestCase { + func testCommandCompletionFeedbackUsesSystemNotificationForBottomOverlayPreference() { + XCTAssertEqual( + NotchOverlayManager.commandCompletionFeedbackPresentation( + overlayPosition: .bottom, + activeScreenHasHardwareNotch: true + ), + .systemNotification + ) + + XCTAssertEqual( + NotchOverlayManager.commandCompletionFeedbackPresentation( + overlayPosition: .bottom, + activeScreenHasHardwareNotch: false + ), + .systemNotification + ) + } + + func testCommandCompletionFeedbackUsesNotchOnlyForTopPreferenceOnHardwareNotchScreen() { + XCTAssertEqual( + NotchOverlayManager.commandCompletionFeedbackPresentation( + overlayPosition: .top, + activeScreenHasHardwareNotch: true + ), + .notchBadge + ) + + XCTAssertEqual( + NotchOverlayManager.commandCompletionFeedbackPresentation( + overlayPosition: .top, + activeScreenHasHardwareNotch: false + ), + .systemNotification + ) + } + + func testLiveOverlayFallsBackToBottomWhenTopPreferenceHasNoHardwareNotch() { + XCTAssertEqual( + NotchOverlayManager.liveOverlayPresentation( + overlayPosition: .top, + activeScreenHasHardwareNotch: false + ), + .bottomOverlay + ) + + XCTAssertEqual( + NotchOverlayManager.liveOverlayPresentation( + overlayPosition: .top, + activeScreenHasHardwareNotch: true + ), + .notchOverlay + ) + + XCTAssertEqual( + NotchOverlayManager.liveOverlayPresentation( + overlayPosition: .bottom, + activeScreenHasHardwareNotch: true + ), + .bottomOverlay + ) + } +} + +@MainActor +final class SystemNotificationServiceTests: XCTestCase { + func testForegroundCommandNotificationsUseBannerPresentation() { + let options = SystemNotificationService.foregroundPresentationOptions + + XCTAssertTrue(options.contains(.banner)) + XCTAssertTrue(options.contains(.sound)) + } +}