import AgentKit import Foundation /// Live state and host actions used by registered slash commands. /// /// Conversation mutations are shared. Window navigation and run orchestration /// remain injected because their owners differ between macOS and iOS. @MainActor public struct ChatSlashCommandContext { public let platform: ChatSlashCommand.Platform public let conversation: ConversationModel public let configuration: AIConfigurationModel public let skills: SkillLibraryModel public let archive: (() -> Void)? public let newConversation: (() -> Void)? public let compact: (String) -> Void public let send: () -> Void public let authorizeRemoteMCP: () -> Bool public let localized: (String.LocalizationValue) -> String public init( platform: ChatSlashCommand.Platform, conversation: ConversationModel, configuration: AIConfigurationModel, skills: SkillLibraryModel, archive: (() -> Void)? = nil, newConversation: (() -> Void)? = nil, compact: @escaping (String) -> Void, send: @escaping () -> Void, authorizeRemoteMCP: @escaping () -> Bool = { true }, localized: @escaping (String.LocalizationValue) -> String = { String(localized: $0) } ) { self.platform = platform self.conversation = conversation self.configuration = configuration self.skills = skills self.archive = archive self.newConversation = newConversation self.compact = compact self.send = send self.authorizeRemoteMCP = authorizeRemoteMCP self.localized = localized } } /// One independently registered slash command. @MainActor public struct ChatSlashCommandRegistration { public typealias CommandBuilder = (ChatSlashCommandContext) -> ChatSlashCommand? public typealias OptionsBuilder = (ChatSlashCommandContext) -> [ChatSlashCommand.Option] public typealias TrailingTextBuilder = (ChatSlashCommandCompletion.Row, ChatSlashCommandContext) -> String? public typealias CurrentValueMatcher = (ChatSlashCommandCompletion.Row, ChatSlashCommandContext) -> Bool public typealias PreferredOption = (ChatSlashCommandContext) -> String? public typealias Performer = (ChatSlashCommandInput, ChatSlashCommandContext) -> Bool public let id: String let command: CommandBuilder let options: OptionsBuilder let trailingText: TrailingTextBuilder let isCurrent: CurrentValueMatcher let preferredOption: PreferredOption let perform: Performer public init( id: String, command: @escaping CommandBuilder, options: @escaping OptionsBuilder = { _ in [] }, trailingText: @escaping TrailingTextBuilder = { _, _ in nil }, isCurrent: @escaping CurrentValueMatcher = { _, _ in false }, preferredOption: @escaping PreferredOption = { _ in nil }, perform: @escaping Performer ) { self.id = id self.command = command self.options = options self.trailingText = trailingText self.isCurrent = isCurrent self.preferredOption = preferredOption self.perform = perform } } /// The single catalog and executor used by the macOS and mobile composers. /// /// Registration keeps command metadata beside its option and execution policy, /// so adding a command cannot leave another string switch out of sync. @MainActor public struct ChatSlashCommandRegistry { private var registrations: [ChatSlashCommandRegistration] = [] public init() {} public mutating func register(_ registration: ChatSlashCommandRegistration) { precondition( !registrations.contains { $0.id == registration.id }, "Duplicate slash command registration: \(registration.id)" ) registrations.append(registration) } public func commands(in context: ChatSlashCommandContext) -> [ChatSlashCommand] { registrations.compactMap { registration in guard let command = registration.command(context) else { return nil } guard command.platforms.contains(context.platform) else { return nil } return command } .sorted { lhs, rhs in let order = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending } } public func options( for command: ChatSlashCommand, in context: ChatSlashCommandContext ) -> [ChatSlashCommand.Option] { registration(for: command.id)?.options(context) ?? [] } public func completion( for draft: String, in context: ChatSlashCommandContext ) -> ChatSlashCommandCompletion { ChatSlashCommandCompletion.rows( draft: draft, commands: commands(in: context), options: { options(for: $0, in: context) } ) } public func trailingText( for row: ChatSlashCommandCompletion.Row, in context: ChatSlashCommandContext ) -> String? { registration(for: row.commandID)?.trailingText(row, context) } public func isCurrent( _ row: ChatSlashCommandCompletion.Row, in context: ChatSlashCommandContext ) -> Bool { registration(for: row.commandID)?.isCurrent(row, context) == true } public func preferredSelection( in completion: ChatSlashCommandCompletion, context: ChatSlashCommandContext ) -> Int { guard case .option(let command, _) = completion.rows.first, let option = registration(for: command.id)?.preferredOption(context) else { return 0 } return completion.selectionIndex(commandID: command.id, optionID: option) } @discardableResult public func perform(draft: String, in context: ChatSlashCommandContext) -> Bool { guard let input = ChatSlashCommandInput.parse(draft), commands(in: context).contains(where: { $0.id == input.name.lowercased() }), let registration = registration(for: input.name.lowercased()) else { return false } return registration.perform(input, context) } public static func standard() -> Self { var registry = Self() registry.register(archiveRegistration) registry.register(newConversationRegistration) registry.register(planRegistration) registry.register(compactRegistration) registry.register(reasoningRegistration) registry.register(modelRegistration) registry.register(mcpRegistration) registry.register(skillRegistration) return registry } private func registration(for id: String) -> ChatSlashCommandRegistration? { registrations.first { $0.id == id } } } extension ChatSlashCommandCompletion.Row { fileprivate nonisolated var commandID: String { switch self { case .command(let command), .option(let command, _): command.id } } } // MARK: - Standard registrations extension ChatSlashCommandRegistry { private static var archiveRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "archive", command: { context in guard !context.conversation.isEmpty, context.archive != nil else { return nil } return ChatSlashCommand( id: "archive", displayName: context.localized("Archive"), summary: context.localized("Move this conversation to Archive"), icon: .symbol("archivebox"), platforms: [.desktop] ) }, perform: { input, context in guard !context.conversation.isStreaming, !context.conversation.isCompacting, let archive = context.archive else { return false } context.conversation.composer.draft = input.leadingText archive() return true } ) } private static var newConversationRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "new", command: { context in guard !context.conversation.isEmpty, context.newConversation != nil else { return nil } return ChatSlashCommand( id: "new", displayName: context.localized("New chat"), summary: context.localized("Start a new conversation"), icon: .symbol("plus.bubble"), platforms: [.desktop] ) }, perform: { input, context in guard let newConversation = context.newConversation else { return false } context.conversation.composer.draft = input.leadingText newConversation() return true } ) } private static var planRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "plan", command: { context in ChatSlashCommand( id: "plan", displayName: context.localized("Plan mode"), summary: context.conversation.isPlanning ? context.localized("Stop planning and act") : context.localized("Research and propose before acting"), icon: .symbol("lightbulb.max"), argumentHint: context.localized("what to plan") ) }, perform: { input, context in let question = input.argumentText(trimmed: context.platform == .desktop) if question.isEmpty { context.conversation.isPlanning.toggle() context.conversation.composer.draft = input.leadingText } else { context.conversation.isPlanning = true context.conversation.composer.draft = input.leadingText + question if input.leadingText.isEmpty { context.send() } } return true } ) } private static var compactRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "compact", command: { context in guard !context.conversation.isEmpty else { return nil } return ChatSlashCommand( id: "compact", displayName: context.localized("Compact"), summary: context.localized("Summarize older turns to free context"), icon: .contextRing, argumentHint: context.localized("what to emphasize") ) }, perform: { input, context in if context.platform == .mobile, context.conversation.isStreaming || context.conversation.isCompacting { return true } let instructions = input.argumentText(trimmed: context.platform == .desktop) context.conversation.composer.draft = input.leadingText context.compact(instructions) return true } ) } private static var reasoningRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "reasoning", command: { context in guard context.reasoning?.supported.contains(where: { $0 != .off }) == true else { return nil } return ChatSlashCommand( id: "reasoning", displayName: context.localized("Reasoning"), summary: context.localized("How hard the model thinks"), icon: .symbol("brain"), argumentHint: context.localized("effort"), platforms: [.desktop] ) }, options: { context in context.reasoning?.supported.map { ChatSlashCommand.Option(id: $0.rawValue, label: $0.displayName) } ?? [] }, trailingText: { row, context in switch row { case .command: return context.effectiveReasoningEffort.displayName case .option(_, let option): return context.platform == .desktop && option.id == context.effectiveReasoningEffort.rawValue ? context.localized("Current") : nil } }, isCurrent: { row, context in guard case .option(_, let option) = row else { return false } return option.id == context.effectiveReasoningEffort.rawValue }, preferredOption: { $0.effectiveReasoningEffort.rawValue }, perform: { input, context in guard case .argument(_, _, let argument) = input, let effort = ReasoningEffort(rawValue: argument.lowercased()), context.reasoning?.supported.contains(effort) == true else { return false } context.conversation.reasoningEffort = effort context.conversation.composer.draft = input.leadingText return true } ) } private static var modelRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "model", command: { context in guard !context.modelChoices.isEmpty else { return nil } return ChatSlashCommand( id: "model", displayName: context.localized("Model"), summary: context.localized("Switch the model for this conversation"), icon: .symbol("sparkles"), argumentHint: context.localized("model"), platforms: [.desktop] ) }, options: { context in context.modelChoices.map { ChatSlashCommand.Option( id: $0.argument, label: $0.model.label, detail: context.platform == .mobile ? $0.provider.name : "" ) } }, trailingText: { row, context in switch row { case .command: return context.resolved?.model.label ?? context.localized("No model") case .option(_, let option): guard context.platform == .desktop else { return nil } return context.modelChoices.first { $0.argument == option.id }?.provider.name } }, isCurrent: { row, context in guard case .option(_, let option) = row else { return false } return option.id == context.resolvedModelArgument }, preferredOption: { $0.resolvedModelArgument }, perform: { input, context in guard case .argument(_, _, let argument) = input, let choice = context.modelChoices.first(where: { $0.argument == argument }) else { return false } let reference = AIModelRef(modelProviderID: choice.provider.id, modelID: choice.model.id) context.conversation.model = reference context.configuration.select(reference) context.conversation.reasoningEffort = ModelCapabilityResolver.reasoning( model: choice.model, provider: choice.provider ).clamp(context.conversation.reasoningEffort) context.conversation.composer.draft = input.leadingText return true } ) } private static var mcpRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "mcp", command: { context in guard !context.configuration.mcpServers.isEmpty else { return nil } return ChatSlashCommand( id: "mcp", displayName: context.localized("MCP"), summary: context.localized("Enable or disable MCP servers"), icon: .symbol("server.rack"), argumentHint: context.localized("server"), platforms: [.desktop] ) }, options: { context in context.configuration.mcpServers.map { ChatSlashCommand.Option(id: $0.id.uuidString.lowercased(), label: $0.name) } }, trailingText: { row, context in guard case .option(_, let option) = row, let id = UUID(uuidString: option.id), let server = context.configuration.mcpServer(id) else { return nil } return server.isEnabled ? context.localized("Enabled") : context.localized("Disabled") }, perform: { input, context in guard case .argument(_, _, let argument) = input, let id = UUID(uuidString: argument), let server = context.configuration.mcpServer(id), context.authorizeRemoteMCP() else { return false } context.configuration.setServerEnabled(!server.isEnabled, for: id) context.conversation.composer.draft = input.leadingText return true } ) } private static var skillRegistration: ChatSlashCommandRegistration { ChatSlashCommandRegistration( id: "skill", command: { context in guard !context.skills.enabledSkills.isEmpty else { return nil } return ChatSlashCommand( id: "skill", displayName: context.localized("Skill"), summary: context.localized("Use an installed skill"), icon: .symbol("book.closed"), argumentHint: context.localized("skill") ) }, options: { context in context.skills.enabledSkills.map { ChatSlashCommand.Option( id: $0.effectiveName, label: $0.displayTitle, detail: context.platform == .mobile ? $0.summary : "" ) } }, trailingText: { row, _ in guard case .option(_, let option) = row else { return nil } return option.id }, isCurrent: { row, context in guard case .option(_, let option) = row else { return false } return option.id == context.conversation.composer.armedSkillName }, perform: { input, context in guard let selection = input.selection, context.skills.enabledSkills.contains(where: { $0.effectiveName == selection.name }) else { return false } context.conversation.composer.armedSkillName = selection.name context.conversation.composer.draft = input.leadingText + selection.remainder if input.leadingText.isEmpty, !selection.remainder.isEmpty { context.send() } return true } ) } } extension ChatSlashCommandContext { fileprivate typealias ModelChoice = (argument: String, provider: ModelProvider, model: AIModel) fileprivate var modelChoices: [ModelChoice] { configuration.enabledModelProviders.flatMap { provider in provider.assistantModels.map { model in ( ChatSlashCommand.argumentID( scope: provider.id.uuidString.lowercased(), value: model.id ), provider, model ) } } } fileprivate var resolved: (provider: ModelProvider, model: AIModel)? { configuration.resolve(conversation: conversation.model) } fileprivate var reasoning: ModelCapabilityResolver.ReasoningResolution? { guard let resolved else { return nil } return ModelCapabilityResolver.reasoning(model: resolved.model, provider: resolved.provider) } fileprivate var effectiveReasoningEffort: ReasoningEffort { reasoning?.clamp(conversation.reasoningEffort) ?? .off } fileprivate var resolvedModelArgument: String? { guard let resolved else { return nil } return ChatSlashCommand.argumentID( scope: resolved.provider.id.uuidString.lowercased(), value: resolved.model.id ) } } extension ChatSlashCommandInput { fileprivate nonisolated func argumentText(trimmed: Bool) -> String { guard case .argument(_, _, let text) = self else { return "" } return trimmed ? text.trimmingCharacters(in: .whitespacesAndNewlines) : text } }