| 1 | import Foundation |
| 2 | |
| 3 | // 🤖 The native mirror of the web's default-command rule (apps.js |
| 4 | // defaultCommandFor + each app's `defaultCommand`/`composerPlaceholder` in |
| 5 | // web/static/apps/*.js): in a personal app-thread with NO bot member, plain |
| 6 | // composer text runs the app's default command instead of sending a chat |
| 7 | // message nobody reads — typing "buy milk" into To-do just adds it. With a bot |
| 8 | // present the rule steps aside (plain text is a real message for the LLM), and |
| 9 | // slash text never falls through — a leading "/" always means command. |
| 10 | public enum AppDefaultCommand { |
| 11 | public struct Spec: Equatable { |
| 12 | public let eventType: String // the app event plain text emits ("todo.add") |
| 13 | // The verbs that add IN this thread, verb-stripped: the local command |
| 14 | // first ("/add" — the drift gate pins it to todo.js defaultCommand), |
| 15 | // then the app's global alias ("/todo"), which the web also runs here. |
| 16 | public let verbs: [String] |
| 17 | public let placeholder: String // the composer's advertisement of the routing |
| 18 | public let desc: String // the local verb's typeahead one-liner ("Add a to-do") |
| 19 | // The bare-verb push-back, shown inline like the web's in-thread usage |
| 20 | // reply — a bare "/add" or "/remind" must get feedback, never silence. |
| 21 | public let usage: String |
| 22 | } |
| 23 | |
| 24 | // One entry per app that declares a defaultCommand on the web — keep in |
| 25 | // sync with web/static/apps/*.js (like AppChatProjection's tables). |
| 26 | // Reminders' verb list is just its global /remind: the web made the global |
| 27 | // the default (its "what + when" IS the in-thread grammar), so there's no |
| 28 | // local twin to lead with. |
| 29 | static let specs: [String: Spec] = [ |
| 30 | "todo": Spec(eventType: "todo.add", verbs: ["/add", "/todo"], |
| 31 | placeholder: "Add a to-do…", desc: "Add a to-do", |
| 32 | usage: "Usage: /add <task> — e.g. /add buy milk"), |
| 33 | "remind": Spec(eventType: "remind.add", verbs: ["/remind"], |
| 34 | placeholder: "Remind me… e.g. call mum in 2h", desc: "Set a reminder", |
| 35 | usage: remindWhenPrompt), |
| 36 | ] |
| 37 | |
| 38 | // remind.js's "when?" push-back, shown when routed text has no parseable |
| 39 | // time — the same wording as the web's usage reply. |
| 40 | public static let remindWhenPrompt = |
| 41 | "When should I remind you? Try “call mum in 10 minutes”, “standup at 9:00”, or “renew passport | 2026-08-01”." |
| 42 | |
| 43 | /// The default-command spec for a thread, or nil when the rule is off: |
| 44 | /// not an app-thread, no default declared, or a bot member present. |
| 45 | public static func spec(appID: String?, hasBot: Bool) -> Spec? { |
| 46 | guard let appID, !hasBot else { return nil } |
| 47 | return specs[appID] |
| 48 | } |
| 49 | |
| 50 | /// The app-local commands the "/" typeahead lists inside `appID`'s own |
| 51 | /// thread — the native slice of the web's commandCatalog(threadId), where |
| 52 | /// local verbs lead and the app's global alias drops out as redundant. |
| 53 | /// Unlike spec(), NOT bot-gated: the verb works in the thread either way. |
| 54 | public static func localCommands(appID: String?) -> [(name: String, desc: String)] { |
| 55 | guard let appID, let spec = specs[appID], let verb = spec.verbs.first else { return [] } |
| 56 | return [(name: verb, desc: spec.desc)] |
| 57 | } |
| 58 | |
| 59 | /// What the composer should do with a draft in a default-command thread. |
| 60 | public enum Routing: Equatable { |
| 61 | case event(String) // emit the app event carrying this text |
| 62 | // Consumed with nothing to emit (a bare verb): the caller keeps the |
| 63 | // draft and sends NOTHING — the native stand-in for the web's private |
| 64 | // usage reply, and never a dead chat bubble. |
| 65 | case swallow |
| 66 | case passthrough // not ours — the normal send path decides |
| 67 | } |
| 68 | |
| 69 | /// Classify a draft. Plain text routes wholesale; one of the app's verbs |
| 70 | /// ("/add buy milk", or the global "/todo buy milk" exactly as the web runs |
| 71 | /// it in-thread) routes with the verb stripped; a bare verb is swallowed; |
| 72 | /// any other slash text passes through (a "/" always means command). |
| 73 | public static func routing(_ text: String, spec: Spec) -> Routing { |
| 74 | let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 75 | guard !trimmed.isEmpty else { return .passthrough } |
| 76 | if trimmed.first != "/" { return .event(trimmed) } |
| 77 | let lower = trimmed.lowercased() |
| 78 | for verb in spec.verbs { |
| 79 | if lower == verb { return .swallow } // bare verb — nothing to add |
| 80 | if lower.hasPrefix(verb + " ") { |
| 81 | let args = trimmed.dropFirst(verb.count).trimmingCharacters(in: .whitespaces) |
| 82 | return args.isEmpty ? .swallow : .event(args) |
| 83 | } |
| 84 | } |
| 85 | return .passthrough |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | extension ChatFeedModel { |
| 90 | /// The composer-facing outcome of routing a draft through a thread's |
| 91 | /// default command — the ONE shared entry point both native composers call |
| 92 | /// (NativeChatScreen on iOS, NativeFeedView on the Mac), so the platforms |
| 93 | /// can't drift on the contract. |
| 94 | public enum DefaultCommandOutcome: Equatable { |
| 95 | case sent // event emitted — clear the draft |
| 96 | case kept // consumed, nothing sent (bare verb, or a down socket) — keep the draft |
| 97 | case rejected(String) // consumed with a push-back to SHOW ("when should I remind you?") — keep the draft |
| 98 | case notHandled // not ours — continue to the normal send path |
| 99 | } |
| 100 | |
| 101 | /// Async because Reminders schedules its fire on the server before |
| 102 | /// emitting (remind.js addReminder's order); To-do resolves immediately. |
| 103 | public func routeDefaultCommand(_ draft: String, spec: AppDefaultCommand.Spec) async -> DefaultCommandOutcome { |
| 104 | switch AppDefaultCommand.routing(draft, spec: spec) { |
| 105 | case .passthrough: return .notHandled |
| 106 | case .swallow: |
| 107 | // A bare verb gets the usage line, like the web's in-thread reply |
| 108 | // — silence would leave the platforms answering the same keystroke |
| 109 | // differently. |
| 110 | return .rejected(spec.usage) |
| 111 | case .event(let text): |
| 112 | if spec.eventType == "remind.add" { |
| 113 | // Timeless text is the app pushing back, not an error path — |
| 114 | // the composer shows the prompt and keeps the draft for fixing. |
| 115 | guard let p = ReminderParse.parse(text) else { |
| 116 | return .rejected(AppDefaultCommand.remindWhenPrompt) |
| 117 | } |
| 118 | return await addReminder(text: p.text, when: p.when) ? .sent : .kept |
| 119 | } |
| 120 | return sendAppEvent(type: spec.eventType, text: text) ? .sent : .kept |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | |