diff --git a/apps/linows/src-tauri/Cargo.lock b/apps/linows/src-tauri/Cargo.lock index 3992baa9..6d886e24 100644 --- a/apps/linows/src-tauri/Cargo.lock +++ b/apps/linows/src-tauri/Cargo.lock @@ -2501,6 +2501,9 @@ dependencies = [ [[package]] name = "look-matching" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "look-netspeed" diff --git a/apps/macos/LauncherApp/LauncherLogicTests/SyntheticRowTests.swift b/apps/macos/LauncherApp/LauncherLogicTests/SyntheticRowTests.swift index 9715af90..4d3f3fcc 100644 --- a/apps/macos/LauncherApp/LauncherLogicTests/SyntheticRowTests.swift +++ b/apps/macos/LauncherApp/LauncherLogicTests/SyntheticRowTests.swift @@ -22,6 +22,11 @@ final class SyntheticRowTests: XCTestCase { ("\(AppConstants.Launcher.PrefixSuggestion.resultIDPrefix)f\"", "prefixSuggestion"), ("\(AppConstants.Launcher.Calc.resultIDPrefix)42", "calc"), ("\(AppConstants.Launcher.CommandSuggestion.resultIDPrefix)calc", "commandSuggestion"), + ( + AppConstants.Launcher.Meeting.resultID(url: "https://meet.jit.si/standup"), + "meeting" + ), + (AppConstants.Launcher.Call.resultID(url: "facetime-audio://+15551234567"), "call"), ] for (id, expected) in cases { XCTAssertEqual(name(of: SyntheticRow.classify(resultID: id)), expected, id) @@ -43,6 +48,8 @@ final class SyntheticRowTests: XCTestCase { case .commandSuggestion: "commandSuggestion" case .webURL: "webURL" case .calc: "calc" + case .meeting: "meeting" + case .call: "call" case nil: "nil" } } diff --git a/apps/macos/LauncherApp/look-app.xcodeproj/project.pbxproj b/apps/macos/LauncherApp/look-app.xcodeproj/project.pbxproj index 3063e9e8..b3fe7553 100644 --- a/apps/macos/LauncherApp/look-app.xcodeproj/project.pbxproj +++ b/apps/macos/LauncherApp/look-app.xcodeproj/project.pbxproj @@ -293,6 +293,7 @@ INFOPLIST_KEY_NSAppleEventsUsageDescription = "Look uses Finder to empty the Trash and read how many items it contains."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Look reads and toggles Bluetooth power from the launcher's quick actions."; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Look creates and edits calendar events when you ask it to."; + INFOPLIST_KEY_NSContactsUsageDescription = "Look finds who to message or FaceTime when you type a name."; INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "Look creates and completes reminders when you ask it to."; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -338,6 +339,7 @@ INFOPLIST_KEY_NSAppleEventsUsageDescription = "Look uses Finder to empty the Trash and read how many items it contains."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Look reads and toggles Bluetooth power from the launcher's quick actions."; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Look creates and edits calendar events when you ask it to."; + INFOPLIST_KEY_NSContactsUsageDescription = "Look finds who to message or FaceTime when you type a name."; INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "Look creates and completes reminders when you ask it to."; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; diff --git a/apps/macos/LauncherApp/look-app/Models/LauncherResult.swift b/apps/macos/LauncherApp/look-app/Models/LauncherResult.swift index bd88163a..b84f7281 100644 --- a/apps/macos/LauncherApp/look-app/Models/LauncherResult.swift +++ b/apps/macos/LauncherApp/look-app/Models/LauncherResult.swift @@ -22,7 +22,10 @@ struct LauncherResult: Identifiable { let title: String let subtitle: String? let path: String - let score: Int + /// `var`, not `let`: most rows are built with their final rank, but the + /// call rows order themselves after the fact. Its POSITION is load-bearing + /// - the memberwise initializer is called positionally all over the app. + var score: Int var clipboardContent: String? = nil var clipboardCapturedAt: Date? = nil var clipboardCharacterCount: Int? = nil @@ -38,4 +41,9 @@ struct LauncherResult: Identifiable { /// grouped display value. var calcExpression: String? = nil var calcRawValue: String? = nil + /// Set on the synthetic rows that open a URL (a meeting to join, a way to + /// reach a person): what the preview shows without re-parsing the subtitle + /// it was written into. The URL itself rides in the result id. + var linkKindLabel: String? = nil + var linkDetail: String? = nil } diff --git a/apps/macos/LauncherApp/look-app/Support/Actions/ActionController+Links.swift b/apps/macos/LauncherApp/look-app/Support/Actions/ActionController+Links.swift new file mode 100644 index 00000000..5842a640 --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Actions/ActionController+Links.swift @@ -0,0 +1,187 @@ +import AppKit +import Foundation + +/// The two tiers that end in "open a URL from a list": `join` a meeting and +/// `call` a person. +/// +/// Split out of `ActionController` because they share a shape and nothing else +/// does: resolve a name against a platform store (EventKit, Contacts), settle +/// access first so "cannot see" never reads as "nothing found", and hand back +/// a `LinkPicker` for the user to choose from. +extension ActionController { + /// Joinable meetings for the user to pick from. Returns feedback only when + /// there is nothing to show. + func presentJoinChoices(named name: String?, didAsk: Bool = false) -> String { + // No access reads downstream as "no meetings", so settle it first. + switch EventKitService.shared.calendarAccess { + case .authorized: + break + case .notDetermined: + // Once only: a failed request leaves the status unchanged, and + // retrying on that loops. + guard !didAsk else { return Self.noCalendarAccess } + Task { + await EventKitService.shared.requestCalendarAccess() + setFeedback(presentJoinChoices(named: name, didAsk: true)) + } + return "" + // Write-only cannot read events. + case .writeOnly, .denied, .restricted: + setLinkPicker(nil) + return Self.noCalendarAccess + } + + let wanted = name ?? "" + let outcome = MeetingService.shared.outcome(name: wanted) + guard !outcome.meetings.isEmpty else { + setLinkPicker(nil) + return Self.nothingToJoin(wanted: wanted, withoutLink: outcome.withoutLink) + } + setLinkPicker( + LinkPicker(header: "Join which?", options: outcome.meetings.map(Self.option), selected: 0)) + return "" + } + + /// One meeting as a picker row. + private static func option(_ meeting: JoinableMeeting) -> LinkOption { + var detail = [meeting.providerLabel, MeetingTiming.phrase(meeting)] + if let host = URL(string: meeting.url)?.host { detail.append(host) } + return LinkOption( + // Not the URL alone: a personal room and a recurring series + // repeat it, and duplicate ids break row identity. + id: "\(meeting.startUnixS)|\(meeting.url)", + title: meeting.title, + detail: detail.joined(separator: " · "), + symbol: "video.fill", + url: meeting.url) + } + + /// Ways to reach the person the user named. Always lists, even for one + /// option: a call has no undo, so the row the user reads is the confirm. + func presentCallChoices(named name: String, modality: String?, didAsk: Bool = false) + -> String + { + switch ContactsService.shared.access { + case .authorized: + break + case .notDetermined: + // Once only: see the calendar branch. + guard !didAsk else { return Self.noContactsAccess } + Task { + await ContactsService.shared.requestAccess() + setFeedback(presentCallChoices(named: name, modality: modality, didAsk: true)) + } + return "" + case .writeOnly, .denied, .restricted: + setLinkPicker(nil) + return Self.noContactsAccess + } + + // The verb did not say how. + let wantedModality = modality ?? EngineBridge.shared.defaultCallModality + let matches = ContactsService.shared.matches(name: name) + let options: [LinkOption] = matches.flatMap { match in + match.handles + .filter { $0.modalityID == wantedModality } + .compactMap { handle in Self.option(match: match, handle: handle) } + } + + guard !options.isEmpty else { + setLinkPicker(nil) + guard !matches.isEmpty else { + return "No contact matching \u{201C}\(name)\u{201D}." + } + // Found the person, but no handle for this modality. + return "\u{201C}\(matches[0].name)\u{201D} has no number or address for that." + } + + setLinkPicker( + LinkPicker( + header: matches.count > 1 ? "Reach who?" : "Reach how?", + options: options, + selected: 0)) + return "" + } + + private static func option(match: ContactMatch, handle: ContactHandle) -> LinkOption? { + guard let url = EngineBridge.shared.callURL(modality: handle.modalityID, handle: handle.handle) + else { return nil } + var detail = [handle.modalityLabel] + if let label = handle.handleLabel, !label.isEmpty { detail.append(label) } + detail.append(handle.handle) + return LinkOption( + id: "\(match.id)|\(handle.id)", + title: match.name, + detail: detail.joined(separator: " · "), + symbol: handle.modalityID == "message" ? "message.fill" : "video.fill", + url: url) + } + + private static let noContactsAccess = + "Look has no contacts access, so it cannot find who you mean. " + + "Grant it in Settings (\u{2318}\u{21E7},) under Permissions." + + private static let noCalendarAccess = + "Look has no calendar access, so it cannot see your meetings. " + + "Grant it in Settings (\u{2318}\u{21E7},) under Permissions." + + /// Why there was nothing to open. A meeting with no link is named, since + /// that is a different problem from having no such meeting. + private static func nothingToJoin(wanted: String, withoutLink: [String]) -> String { + let where_ = "Add a Zoom, Teams, or Meet link to its URL, location, or notes." + switch withoutLink.count { + case 0: + guard !wanted.isEmpty else { return "No meeting to join in the next two days." } + return "No meeting matching \u{201C}\(wanted)\u{201D} in the next two days." + case 1: + return "\u{201C}\(withoutLink[0])\u{201D} has no meeting link. \(where_)" + default: + let named = withoutLink.map { "\u{201C}\($0)\u{201D}" }.joined(separator: ", ") + return "No join link on \(named). \(where_)" + } + } + + /// Opens the highlighted row. Returns whether it opened, so the caller can + /// hide the launcher. Success leaves no feedback: a sticky bar would block + /// the sessions list, which reads feedback as "busy". + @discardableResult + func openSelectedLink() -> Bool { + guard let picker = linkPicker, let option = picker.selectedOption else { return false } + // Cleared only on success, so a failed open leaves the list to retry. + guard let url = URL(string: option.url), NSWorkspace.shared.open(url) else { + setFeedback("Could not open \(option.title).") + return false + } + setLinkPicker(nil) + setFeedback("") + return true + } + + /// Tab / arrows roll the picker. False when none is up, so the key falls + /// through. + @discardableResult + func movePickerSelection(forward: Bool) -> Bool { + guard var picker = linkPicker, !picker.options.isEmpty else { return false } + let count = picker.options.count + picker.selected = + forward + ? (picker.selected >= count - 1 ? 0 : picker.selected + 1) + : (picker.selected <= 0 ? count - 1 : picker.selected - 1) + setLinkPicker(picker) + return true + } + + /// Moves the highlight to a 1-based position. False when the number names + /// no row, so it stays an ordinary message. + @discardableResult + func selectPickerRow(number: Int) -> Bool { + guard var picker = linkPicker, number >= 1, number <= picker.options.count else { + return false + } + picker.selected = number - 1 + setLinkPicker(picker) + return true + } + + func clearPicker() { setLinkPicker(nil) } +} diff --git a/apps/macos/LauncherApp/look-app/Support/Actions/ActionController.swift b/apps/macos/LauncherApp/look-app/Support/Actions/ActionController.swift index a4d6126c..9284a711 100644 --- a/apps/macos/LauncherApp/look-app/Support/Actions/ActionController.swift +++ b/apps/macos/LauncherApp/look-app/Support/Actions/ActionController.swift @@ -61,6 +61,33 @@ final class ActionController: ObservableObject { let candidates: [ActionCandidate] } + /// One openable thing in a picker: a meeting to join, a way to reach a + /// person. Everything the row needs, plus the URL that IS the action. + struct LinkOption: Identifiable, Equatable { + let id: String + let title: String + /// The line that says what this row will actually do. + let detail: String + let symbol: String + let url: String + } + + /// A list of things to open, and which one the keyboard is on. + /// + /// Shared by `join` and `call` because both end the same way: the user + /// picks a row and a URL opens. Its own type rather than `PendingChoice` - + /// that one carries a tool call to re-propose, and neither of these is a + /// tool. + struct LinkPicker: Equatable { + let header: String + var options: [LinkOption] + var selected: Int + + var selectedOption: LinkOption? { + options.indices.contains(selected) ? options[selected] : nil + } + } + /// The file paths picked in the launcher, pushed by `LauncherView` as the /// picks change (this is a singleton with no view context). Picks survive /// the query changing to `>summarize`, which the row selection does not, so @@ -82,6 +109,8 @@ final class ActionController: ObservableObject { /// leave half of itself behind with no way back. @Published private(set) var pendingSteps: [PlannedAction] = [] @Published private(set) var pendingChoice: PendingChoice? + /// The rows a `join` or `call` turned up, or nil when nothing is pending. + @Published private(set) var linkPicker: LinkPicker? /// Receipts from the last confirmed plan, in the order they ran. Undo /// reverses them back to front. @Published private(set) var lastReceipts: [ActionReceipt] = [] @@ -111,6 +140,14 @@ final class ActionController: ObservableObject { /// A leftover disambiguation must not survive into an unrelated turn. func clearPendingChoice() { pendingChoice = nil } + /// Writers for the extensions that live in other files (see + /// `ActionController+Links.swift`). The properties stay `private(set)` so + /// nothing outside this controller can write them, and in Swift a private + /// setter is scoped to the FILE - an extension elsewhere cannot assign it, + /// only call through. + func setLinkPicker(_ picker: LinkPicker?) { linkPicker = picker } + func setFeedback(_ text: String) { feedback = text } + /// A listing the user just saw becomes the referent set: "remove this /// event" right after "what's on this week?" targets what was listed. func rememberListed(_ listing: ScheduleContextProvider.Listing) { @@ -275,6 +312,14 @@ final class ActionController: ObservableObject { isPlanning = false pendingSteps = [] feedback = memoryFeedback + case .join(let name): + isPlanning = false + pendingSteps = [] + feedback = presentJoinChoices(named: name) + case .call(let name, let modality): + isPlanning = false + pendingSteps = [] + feedback = presentCallChoices(named: name, modality: modality) case .textOp(let label, let instruction): isPlanning = false pendingSteps = [] @@ -687,6 +732,7 @@ final class ActionController: ObservableObject { planGeneration += 1 pendingSteps = [] pendingChoice = nil + linkPicker = nil feedback = "" isPlanning = false } diff --git a/apps/macos/LauncherApp/look-app/Support/Actions/ChatSessionController.swift b/apps/macos/LauncherApp/look-app/Support/Actions/ChatSessionController.swift index 1f75cf0b..11f5d517 100644 --- a/apps/macos/LauncherApp/look-app/Support/Actions/ChatSessionController.swift +++ b/apps/macos/LauncherApp/look-app/Support/Actions/ChatSessionController.swift @@ -109,7 +109,7 @@ final class ChatSessionController: ObservableObject { guard !items.isEmpty else { return } ConversationStore.upsert(AIConversation( id: conversationID, - title: String((items.first?.text ?? "Conversation").prefix(48)), + title: AIConversation.singleLine(items.first?.text ?? "Conversation"), updatedAt: Date(), items: items.map { AIConversation.StoredItem( diff --git a/apps/macos/LauncherApp/look-app/Support/Actions/ConversationStore.swift b/apps/macos/LauncherApp/look-app/Support/Actions/ConversationStore.swift index 1e2d7c4c..83f1d10a 100644 --- a/apps/macos/LauncherApp/look-app/Support/Actions/ConversationStore.swift +++ b/apps/macos/LauncherApp/look-app/Support/Actions/ConversationStore.swift @@ -14,6 +14,23 @@ struct AIConversation: Codable, Identifiable { var title: String var updatedAt: Date var items: [StoredItem] + + /// How much of the first message becomes the title. + static let titleLimit = 48 + + /// One line, whitespace collapsed. A title is drawn in a list row and in the + /// delete banner, and the message it comes from can carry newlines (pasted + /// text, or Shift+Enter in the composer) - which render as a stack of short + /// rows rather than one line. Applied when the title is MADE and again when + /// it is DRAWN, so conversations stored before this stay tidy too. + static func singleLine(_ text: String, limit: Int = titleLimit) -> String { + String(text.split(whereSeparator: \.isWhitespace).joined(separator: " ").prefix(limit)) + } + + /// The title as one line, for any surface that draws it. + func displayTitle(limit: Int = AIConversation.titleLimit) -> String { + AIConversation.singleLine(title, limit: limit) + } } /// Thin shell over the Rust-core conversation store (core/ai), which owns the diff --git a/apps/macos/LauncherApp/look-app/Support/AppConstants.swift b/apps/macos/LauncherApp/look-app/Support/AppConstants.swift index a298da0c..7faadedc 100644 --- a/apps/macos/LauncherApp/look-app/Support/AppConstants.swift +++ b/apps/macos/LauncherApp/look-app/Support/AppConstants.swift @@ -94,6 +94,30 @@ enum AppConstants { } } + /// The ⌘-digit chips on the AI sessions list. A ⌘ chord is ONE + /// keypress, so there is no ⌘10 and ten rows is the hard ceiling: + /// ⌘1…⌘9 then ⌘0 for the tenth. Older sessions are reached by typing + /// (the list filters on title and content), Tab/↑↓, then Enter. + enum AISessions { + /// Rows carrying a chip, and therefore how many the list shows. + static let jumpKeyLimit = 10 + /// The tenth row wraps onto `0`, the key sitting next to `9`. + private static let lastRowDigit = 0 + + /// The digit shown on row `index`, or nil past the mapped rows. + static func jumpDigit(forRow index: Int) -> Int? { + guard index >= 0, index < jumpKeyLimit else { return nil } + return index == jumpKeyLimit - 1 ? lastRowDigit : index + 1 + } + + /// The row ⌘`digit` addresses, or nil when the digit maps to none. + static func row(forJumpDigit digit: Int) -> Int? { + if digit == lastRowDigit { return jumpKeyLimit - 1 } + guard digit > 0, digit < jumpKeyLimit else { return nil } + return digit - 1 + } + } + enum QueryPrefix { static let apps = "a\"" static let files = "f\"" @@ -203,6 +227,39 @@ enum AppConstants { // Synthesized calculator row, pinned above everything else while the // query is arithmetic (shared `core/calc` intent gate via EngineBridge). // Like WebSuggestion/WebURL, told apart from real candidates by id. + /// The synthesized "Join " row. Told apart from real + /// candidates by id; the join URL rides in it, so pressing Enter never + /// has to re-read the calendar. + enum Meeting { + static let resultIDPrefix = "meeting:" + + static func resultID(url: String) -> String { + resultIDPrefix + url + } + + /// Recovers the join URL encoded in a result id, or nil. + static func url(fromResultID resultID: String) -> String? { + guard resultID.hasPrefix(resultIDPrefix) else { return nil } + return String(resultID.dropFirst(resultIDPrefix.count)) + } + } + + /// The synthesized "Call " rows. Like `Meeting`, the URL rides + /// in the id, so pressing Enter never re-reads Contacts and can never + /// dial someone other than the row the user read. + enum Call { + static let resultIDPrefix = "call:" + + static func resultID(url: String) -> String { + resultIDPrefix + url + } + + static func url(fromResultID resultID: String) -> String? { + guard resultID.hasPrefix(resultIDPrefix) else { return nil } + return String(resultID.dropFirst(resultIDPrefix.count)) + } + } + enum Calc { static let resultIDPrefix = "calc:" static let enterToCopyHint = "Enter to copy" diff --git a/apps/macos/LauncherApp/look-app/Support/Calendar/ContactsService.swift b/apps/macos/LauncherApp/look-app/Support/Calendar/ContactsService.swift new file mode 100644 index 00000000..d94faa76 --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Calendar/ContactsService.swift @@ -0,0 +1,130 @@ +import Contacts +import Foundation + +/// A parsed "call ..." line. Mirrors `look_ai::calling::CallRequest`. +nonisolated struct CallRequest: Decodable, Equatable { + /// The words naming the person, to match against Contacts. + let name: String + /// A `Modality` id, or nil when the line did not say and the default + /// applies. + let modality: String? +} + +/// One way to reach a person: a handle, and what it can be used for. +nonisolated struct ContactHandle: Equatable, Identifiable { + /// A `Modality` id from `look_ai::calling`, chosen when the handle was read + /// (a phone number can message or call; an email can only FaceTime). + let modalityID: String + let modalityLabel: String + /// As Contacts stores it, for display. `call_url` normalises it for dialling. + let handle: String + /// The Contacts label ("mobile", "work"), when there is one. + let handleLabel: String? + + var id: String { "\(modalityID)|\(handle)" } +} + +/// A person Look could reach, with every way to reach them. +nonisolated struct ContactMatch: Equatable, Identifiable { + let id: String + let name: String + let handles: [ContactHandle] +} + +/// Contacts lookup for the `call` tier. Reads only what a call needs - a name, +/// phone numbers, and email addresses - and never leaves the machine. +nonisolated final class ContactsService: @unchecked Sendable { + static let shared = ContactsService() + + private enum Metrics { + /// Enough for a picker; past this, type more of the name. + static let matchLimit = 8 + /// The call row is a computed property; without this, several lookups + /// per keystroke. + static let cacheTTL: TimeInterval = 5 + } + + private let store = CNContactStore() + /// Keyed on the name: a different name is a different lookup. + private let found = TimedCache(ttl: Metrics.cacheTTL) + + private init() {} + + var access: CalendarAccess { + switch CNContactStore.authorizationStatus(for: .contacts) { + case .authorized: return .authorized + case .notDetermined: return .notDetermined + case .denied: return .denied + case .restricted: return .restricted + // `.limited` (macOS 26) is partial access to a chosen subset. Treated as + // authorized: what it hands over is what Look can act on. + @unknown default: return .authorized + } + } + + func requestAccess() async { + _ = try? await store.requestAccess(for: .contacts) + } + + /// People whose name matches `name`, each with the handles a call can use. + /// Empty without access, so the caller must check `access` first to tell + /// "no such person" from "Look cannot look". + func matches(name: String, now: Date = Date()) -> [ContactMatch] { + guard access == .authorized, !name.trimmingCharacters(in: .whitespaces).isEmpty else { + return [] + } + return found.value(for: name, now: now) { fetch(name: name) } + } + + private func fetch(name: String) -> [ContactMatch] { + let keys: [CNKeyDescriptor] = [ + CNContactFormatter.descriptorForRequiredKeys(for: .fullName), + CNContactPhoneNumbersKey as CNKeyDescriptor, + CNContactEmailAddressesKey as CNKeyDescriptor, + ] + let predicate = CNContact.predicateForContacts(matchingName: name) + let found = (try? store.unifiedContacts(matching: predicate, keysToFetch: keys)) ?? [] + + return found.prefix(Metrics.matchLimit).compactMap { contact in + let display = CNContactFormatter.string(from: contact, style: .fullName) + let name = display?.trimmingCharacters(in: .whitespaces) ?? "" + let handles = Self.handles(of: contact) + // A contact with no phone and no email cannot be called at all, so + // it is not a match - it would be a row that does nothing. + guard !name.isEmpty, !handles.isEmpty else { return nil } + return ContactMatch(id: contact.identifier, name: name, handles: handles) + } + } + + /// Phone numbers first (they can do everything), then emails, which reach + /// FaceTime only - `sms:` to an address is not a thing. + private static func handles(of contact: CNContact) -> [ContactHandle] { + var handles: [ContactHandle] = [] + for number in contact.phoneNumbers { + let value = number.value.stringValue + let label = number.label.map { CNLabeledValue.localizedString(forLabel: $0) } + for (id, title) in [ + ("message", "Message"), + ("face_time_audio", "FaceTime audio"), + ("face_time_video", "FaceTime video"), + ] { + handles.append( + ContactHandle( + modalityID: id, modalityLabel: title, handle: value, handleLabel: label)) + } + } + for email in contact.emailAddresses { + let value = email.value as String + let label = email.label.map { CNLabeledValue.localizedString(forLabel: $0) } + for (id, title) in [ + ("face_time_audio", "FaceTime audio"), + ("face_time_video", "FaceTime video"), + ] { + handles.append( + ContactHandle( + modalityID: id, modalityLabel: title, handle: value, handleLabel: label)) + } + } + return handles + } +} diff --git a/apps/macos/LauncherApp/look-app/Support/Calendar/EventKitService.swift b/apps/macos/LauncherApp/look-app/Support/Calendar/EventKitService.swift index 119c21f8..00939ef3 100644 --- a/apps/macos/LauncherApp/look-app/Support/Calendar/EventKitService.swift +++ b/apps/macos/LauncherApp/look-app/Support/Calendar/EventKitService.swift @@ -186,6 +186,36 @@ nonisolated final class EventKitService: @unchecked Sendable { } } + /// How many events a join looks at. Generous next to a real day, and a + /// bound on the JSON crossing the FFI. + private static let meetingFetchLimit = 60 + + /// Events in a window, flattened for the meeting core: the three fields a + /// join link hides in, plus what it takes to choose between them. + /// + /// `refreshSourcesIfNecessary` first, because an invite that arrived + /// moments ago may not have synced down yet and "join my next meeting" is + /// asked precisely when a meeting is about to start. It is a hint, not a + /// blocking fetch, so it costs nothing when the store is already current. + func meetingEventPayloads(from: Date, to: Date) -> [MeetingEventPayload] { + guard calendarAccess == .authorized else { return [] } + store.refreshSourcesIfNecessary() + let predicate = store.predicateForEvents(withStart: from, end: to, calendars: nil) + // Capped like `eventsSummary` and `eventCandidates`: this carries full + // `notes` bodies across the FFI, and a packed two-day window on a busy + // calendar is a lot of text to copy for one join. + return store.events(matching: predicate).prefix(Self.meetingFetchLimit).map { event in + MeetingEventPayload( + title: event.title ?? "Untitled", + startUnixS: Int64(event.startDate.timeIntervalSince1970), + endUnixS: Int64(event.endDate.timeIntervalSince1970), + url: event.url?.absoluteString, + location: event.location, + notes: event.notes, + allDay: event.isAllDay) + } + } + /// Event cache mirroring the reminder cache, so the per-keystroke `@` mutate /// preview resolves without a live EventKit fetch each stroke. Warmed while /// composing (throttled) and forced on `.EKEventStoreChanged`. Main-thread diff --git a/apps/macos/LauncherApp/look-app/Support/Calendar/MeetingService.swift b/apps/macos/LauncherApp/look-app/Support/Calendar/MeetingService.swift new file mode 100644 index 00000000..b6f0d99a --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Calendar/MeetingService.swift @@ -0,0 +1,131 @@ +import AppKit +import Foundation + +/// One event, flattened to exactly the fields the Rust core needs to find a +/// join link and pick between meetings. Keys match `look_ai::meeting::EventInput`. +nonisolated struct MeetingEventPayload: Encodable { + let title: String + let startUnixS: Int64 + let endUnixS: Int64 + let url: String? + let location: String? + let notes: String? + let allDay: Bool +} + +/// What a `join` request turned up. Mirrors `look_ai::meeting::JoinOutcome`. +nonisolated struct JoinOutcome: Decodable, Equatable { + var meetings: [JoinableMeeting] = [] + /// Titles that matched the name but carry no join link, so the answer can + /// say which meeting is missing one rather than claiming none exists. + var withoutLink: [String] = [] +} + +/// A parsed `join ...` request. `name` is the words that were not filler, so +/// "join testing" carries "testing" and a bare "join" carries nothing. +nonisolated struct JoinRequest: Decodable, Equatable { + var name: String? +} + +/// The meeting to join, as decided in core. Mirrors +/// `look_ai::meeting::JoinableMeeting`. +nonisolated struct JoinableMeeting: Decodable, Equatable { + let title: String + let startUnixS: Int64 + let endUnixS: Int64 + let url: String + /// Provider id (`teams`, `zoom`, `meet`, ...). `providerLabel` is what to show. + let provider: String + let providerLabel: String + /// Negative once the meeting has started. + let startsInS: Int64 + let inProgress: Bool + + var startDate: Date { Date(timeIntervalSince1970: TimeInterval(startUnixS)) } + var joinURL: URL? { URL(string: url) } + + /// Rounded up, so a meeting 61 seconds out reads "in 2 min" rather than + /// "in 1 min" for most of the minute it is counting down. + var minutesUntilStart: Int { + Int((Double(startsInS) / 60.0).rounded(.up)) + } +} + +/// "Join my next meeting": read the calendar, let core find the link, open it. +/// +/// Look makes no network call here. A Teams, Zoom, or Meet invite already +/// carries its join URL, and the account sync that put it there is the OS's +/// job (see docs/ai-eventkit-connector.md). +nonisolated final class MeetingService: @unchecked Sendable { + static let shared = MeetingService() + + private enum Metrics { + /// Two days, so "my next meeting" on a Friday evening finds Monday's. + static let lookahead: TimeInterval = 48 * 60 * 60 + /// A meeting that started a while ago is still joinable, so the window + /// opens slightly behind now. Core drops anything already ended. + static let lookbehind: TimeInterval = -60 * 60 + /// Mirrors the event cache in `EventKitService`. The join row is a + /// COMPUTED property of the launcher view, so SwiftUI re-reads it on + /// every update; without this, one keystroke would mean several + /// EventKit fetches. + static let cacheTTL: TimeInterval = 5 + } + + /// Keyed on the window, not the name: the name filter is pure text in + /// core, so every query inside the TTL shares one EventKit read. + private let events = TimedCache(ttl: Metrics.cacheTTL) + private static let windowKey = "window" + + private init() {} + + /// What a `join` finds: the joinable meetings, best first, and the titles + /// that matched but carry no link. `name` narrows to meetings whose title + /// holds those words. + /// + /// Cached for `Metrics.cacheTTL`; the countdown shown is derived from each + /// meeting's own start time, so a cached answer is not a stale one. Keyed + /// on the name too, since typing "join st" then "join standup" asks two + /// different questions inside one TTL. + func outcome(name: String = "", now: Date = Date()) -> JoinOutcome { + guard let json = eventsJSON(now: now) else { return JoinOutcome() } + return EngineBridge.shared.joinOutcome( + eventsJSON: json, now: Int64(now.timeIntervalSince1970), name: name) + } + + /// The window's events as JSON, refetched at most once per `cacheTTL`. + private func eventsJSON(now: Date) -> String? { + events.value(for: Self.windowKey, now: now) { + let payloads = EventKitService.shared.meetingEventPayloads( + from: now.addingTimeInterval(Metrics.lookbehind), + to: now.addingTimeInterval(Metrics.lookahead)) + guard !payloads.isEmpty, + let data = try? JSONEncoder().encode(payloads), + let json = String(data: data, encoding: .utf8) + else { return nil } + return json + } + } + + /// Every meeting that could be joined, best first. + func meetings(name: String = "", now: Date = Date()) -> [JoinableMeeting] { + outcome(name: name, now: now).meetings + } + + /// The one a bare "join" would take: the head of the list. + func nextMeeting(name: String = "", now: Date = Date()) -> JoinableMeeting? { + meetings(name: name, now: now).first + } + + /// Drop the cache, for when the calendar changed under us. + func invalidate() { events.invalidate() } + + /// Opens the join link. The https form is deliberate: it reaches the + /// desktop app through universal links when installed, and the browser when + /// not, where `msteams:` / `zoommtg:` would fail silently. + @discardableResult + func join(_ meeting: JoinableMeeting) -> Bool { + guard let url = meeting.joinURL else { return false } + return NSWorkspace.shared.open(url) + } +} diff --git a/apps/macos/LauncherApp/look-app/Support/Calendar/MeetingTiming.swift b/apps/macos/LauncherApp/look-app/Support/Calendar/MeetingTiming.swift new file mode 100644 index 00000000..d3ab8df6 --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Calendar/MeetingTiming.swift @@ -0,0 +1,48 @@ +import Foundation + +/// When a meeting starts, in the words a row has space for. +/// +/// Its own type rather than a helper on the launcher view: the picker is built +/// in `ActionController`, and a controller reaching into a `View` for wording +/// would be the wrong way round. +nonisolated enum MeetingTiming { + private enum Copy { + static let inProgress = "in progress" + static let startingNow = "starting now" + /// Past an hour a countdown in minutes stops being readable ("in 1440 + /// min") and the start time itself is the useful thing to say. + static let minutesPerHour = 60 + } + + /// A meeting under way says so rather than counting negative minutes, and + /// one that is not today says WHEN rather than counting to 1440. + static func phrase(_ meeting: JoinableMeeting) -> String { + if meeting.inProgress { return Copy.inProgress } + let minutes = meeting.minutesUntilStart + guard minutes > 0 else { return Copy.startingNow } + if minutes < Copy.minutesPerHour { return "in \(minutes) min" } + + let start = meeting.startDate + let clock = clockTime.string(from: start) + let calendar = Calendar.current + if calendar.isDateInToday(start) { return "at \(clock)" } + if calendar.isDateInTomorrow(start) { return "tomorrow \(clock)" } + return "\(weekday.string(from: start)) \(clock)" + } + + /// The start time in the user's own clock format. + static let clockTime: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + /// "Thu" - enough to place a meeting inside the two-day window the service + /// looks over. + private static let weekday: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("EEE") + return formatter + }() +} diff --git a/apps/macos/LauncherApp/look-app/Support/Launcher/EngineBridge.swift b/apps/macos/LauncherApp/look-app/Support/Launcher/EngineBridge.swift index 2f7fe811..f5086211 100644 --- a/apps/macos/LauncherApp/look-app/Support/Launcher/EngineBridge.swift +++ b/apps/macos/LauncherApp/look-app/Support/Launcher/EngineBridge.swift @@ -108,6 +108,26 @@ private func look_ai_parse_explicit(_ input: UnsafePointer?, _ modelAvail nonisolated private func look_ai_route(_ memoryPath: UnsafePointer?, _ input: UnsafePointer?, _ modelAvailable: Bool, _ now: Int64) -> UnsafeMutablePointer? +@_silgen_name("look_meeting_join_query_json") +nonisolated +private func look_meeting_join_query_json(_ query: UnsafePointer?) -> UnsafeMutablePointer? + +@_silgen_name("look_call_query_json") +nonisolated +private func look_call_query_json(_ query: UnsafePointer?) -> UnsafeMutablePointer? + +@_silgen_name("look_call_url") +nonisolated +private func look_call_url(_ modality: UnsafePointer?, _ handle: UnsafePointer?) -> UnsafeMutablePointer? + +@_silgen_name("look_call_default_modality") +nonisolated +private func look_call_default_modality() -> UnsafeMutablePointer? + +@_silgen_name("look_meeting_outcome_json") +nonisolated +private func look_meeting_outcome_json(_ eventsJSON: UnsafePointer?, _ now: Int64, _ name: UnsafePointer?) -> UnsafeMutablePointer? + @_silgen_name("look_ai_chat_start") nonisolated private func look_ai_chat_start(_ host: UnsafePointer?, _ model: UnsafePointer?, _ messagesJSON: UnsafePointer?, _ optionsJSON: UnsafePointer?) -> UInt64 @@ -314,10 +334,17 @@ final class EngineBridge: @unchecked Sendable { } /// The Rust-core routing decision for submitted AI-mode input (see - /// core/ai/src/route.rs: memory -> textop -> files -> explicit -> plan -> - /// chat). The memory tier has already executed by the time this returns. + /// core/ai/src/route.rs: memory -> join -> textop -> files -> explicit -> + /// plan -> chat). The memory tier has already executed by the time this + /// returns. enum AIRoute { case memory(feedback: String) + /// "join", "join my next meeting", "join ". The shell resolves + /// the name against the calendar. + case join(name: String?) + /// "call mom", "facetime sarah". `modality` is a `Modality` id, or nil + /// when the words did not say and the default applies. + case call(name: String, modality: String?) case textOp(label: String, instruction: String) case files case explicit(toolID: String, params: [String: String]) @@ -336,6 +363,11 @@ final class EngineBridge: @unchecked Sendable { let label: String? let instruction: String? let call: Call? + /// The join tier's meeting name (absent for a bare "join"), or the + /// call tier's person. + let name: String? + /// The call tier's modality id, absent when the words did not say. + let modality: String? } let now = Int64(Date().timeIntervalSince1970) let ptr = memoryPath.withCString { pathC in @@ -352,6 +384,11 @@ final class EngineBridge: @unchecked Sendable { switch payload.route { case "memory": return .memory(feedback: payload.feedback ?? "") + case "join": + return .join(name: payload.name) + case "call": + guard let name = payload.name, !name.isEmpty else { return .chat } + return .call(name: name, modality: payload.modality) case "textop": guard let instruction = payload.instruction, !instruction.isEmpty else { return .chat } return .textOp(label: payload.label ?? instruction, instruction: instruction) @@ -374,6 +411,68 @@ final class EngineBridge: @unchecked Sendable { let relaxed: String? } + /// The join request in the typed text, or nil when it is an ordinary + /// search. Pure string work in core, so it is safe per keystroke. + nonisolated func joinQuery(_ query: String) -> JoinRequest? { + guard let ptr = query.withCString({ look_meeting_join_query_json($0) }) else { + return nil + } + defer { look_free_cstring(ptr) } + guard let data = String(cString: ptr).data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(JoinRequest.self, from: data) + } + + /// The call request in the typed text, or nil when it is an ordinary + /// search. Pure string work in core, so it is safe per keystroke. + nonisolated func callQuery(_ query: String) -> CallRequest? { + guard let ptr = query.withCString({ look_call_query_json($0) }) else { return nil } + defer { look_free_cstring(ptr) } + guard let data = String(cString: ptr).data(using: .utf8) else { return nil } + // Core answers the literal `null` for a non-call, which fails to decode + // into a non-optional and so becomes the nil this returns anyway. + return try? JSONDecoder().decode(CallRequest.self, from: data) + } + + /// The URL that dials `handle` with `modality`, or nil when the modality + /// is unknown to core. Building it there keeps the schemes in one place. + nonisolated func callURL(modality: String, handle: String) -> String? { + guard + let ptr = modality.withCString({ modalityC in + handle.withCString { look_call_url(modalityC, $0) } + }) + else { return nil } + defer { look_free_cstring(ptr) } + let url = String(cString: ptr) + return url.isEmpty ? nil : url + } + + /// The modality a bare "call" means, straight from core. + nonisolated var defaultCallModality: String { + guard let ptr = look_call_default_modality() else { return "" } + defer { look_free_cstring(ptr) } + return String(cString: ptr) + } + + /// What a `join` finds in `eventsJSON`: the meetings it can open, best + /// first, plus the titles that matched the name but carry no link. `name` + /// narrows to meetings whose title holds those words. The ordering, and + /// where a join link hides inside an event, are decided in core + /// (`look_ai::meeting`) so every shell agrees. + nonisolated func joinOutcome( + eventsJSON: String, now: Int64, name: String = "" + ) -> JoinOutcome { + guard + let ptr = eventsJSON.withCString({ events in + name.withCString { look_meeting_outcome_json(events, now, $0) } + }) + else { + return JoinOutcome() + } + defer { look_free_cstring(ptr) } + guard let data = String(cString: ptr).data(using: .utf8) else { return JoinOutcome() } + return (try? JSONDecoder().decode(JoinOutcome.self, from: data)) ?? JoinOutcome() + } + /// Natural-language file recall over Look's own index. Returns nil when the /// query is not a file-recall query (so the caller does normal search). nonisolated func searchFiles(query: String, limit: Int = 40) -> FileRecallOutcome? { diff --git a/apps/macos/LauncherApp/look-app/Support/Launcher/KeyboardSelectionMonitor.swift b/apps/macos/LauncherApp/look-app/Support/Launcher/KeyboardSelectionMonitor.swift index c96e5600..2b0ab2ed 100644 --- a/apps/macos/LauncherApp/look-app/Support/Launcher/KeyboardSelectionMonitor.swift +++ b/apps/macos/LauncherApp/look-app/Support/Launcher/KeyboardSelectionMonitor.swift @@ -31,6 +31,9 @@ final class KeyboardSelectionMonitor { onExitCommandMode: @escaping @MainActor () -> Void, onHideLauncher: @escaping @MainActor () -> Void, inCommandMode: @escaping @MainActor () -> Bool, + /// AI mode owns some chords the main bar spends elsewhere (Shift+Enter + /// is a line break there, not "open all picked"). + inAIMode: @escaping @MainActor () -> Bool = { false }, onWebSearch: @escaping @MainActor () -> Void, onRevealInFinder: @escaping @MainActor () -> Void, onCopySelection: @escaping @MainActor () -> Bool, @@ -127,17 +130,6 @@ final class KeyboardSelectionMonitor { return nil } - // ⌘+home-row jumps to a listed conversation (keys under the fingers: - // a s d f g h j k l → rows 1-9). Gated on "browsing the sessions - // list", so ⌘S/⌘F/⌘H etc. keep their normal meaning everywhere else. - if flags == [.command], - let ch = event.charactersIgnoringModifiers?.lowercased().first, - let index = "asdfghjkl".firstIndex(of: ch).map({ "asdfghjkl".distance(from: "asdfghjkl".startIndex, to: $0) }), - onActivateSession(index) - { - return nil - } - if (event.keyCode == KeyCode.returnKey || event.keyCode == KeyCode.keypadEnter) && flags == [.command] { onWebSearch() return nil @@ -223,9 +215,11 @@ final class KeyboardSelectionMonitor { // Shift+Enter opens every picked file/folder at once. Only when // there are picks; otherwise fall through so plain submit still - // opens the selected result. + // opens the selected result. In AI mode it always falls through: + // the chord is a line break in the composer, and a pick left over + // from the main bar must not steal it. if (event.keyCode == KeyCode.returnKey || event.keyCode == KeyCode.keypadEnter) && flags == [.shift] { - if !inCommandMode() && hasPickedItems() { + if !inCommandMode() && !inAIMode() && hasPickedItems() { onOpenAllPicked() return nil } @@ -261,7 +255,7 @@ final class KeyboardSelectionMonitor { if event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.control) && !event.modifierFlags.contains(.option) { - // macOS digit keyCodes are not contiguous: 1=18, 2=19, 3=20, 4=21, 5=23, 6=22, 7=26, 8=28, 9=25. + // macOS digit keyCodes are not contiguous: 1=18, 2=19, 3=20, 4=21, 5=23, 6=22, 7=26, 8=28, 9=25, 0=29. let cmdNumberKey: Int? switch event.keyCode { case 18: cmdNumberKey = 1 @@ -273,11 +267,15 @@ final class KeyboardSelectionMonitor { case 26: cmdNumberKey = 7 case 28: cmdNumberKey = 8 case 25: cmdNumberKey = 9 + // Only the sessions list claims 0; everything below is 1-based + // and declines it, so ⌘0 keeps its "Actual Size" meaning + // everywhere else. + case 29: cmdNumberKey = 0 default: cmdNumberKey = nil } if let key = cmdNumberKey { if inCommandMode() { - if key <= AppConstants.Launcher.commandCatalog.count { + if key > 0, key <= AppConstants.Launcher.commandCatalog.count { Self.logger.debug("⌘+\(key, privacy: .public) -> command catalog") DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { onSelectCommandByIndex(key) @@ -287,13 +285,27 @@ final class KeyboardSelectionMonitor { Self.logger.debug( "⌘+\(key, privacy: .public) ignored (command mode maps 1-\(AppConstants.Launcher.commandCatalog.count, privacy: .public))") } else { - Self.logger.debug("⌘+\(key, privacy: .public) -> running-apps switcher") - if onActivateRunningApp(key) { + // AI mode hides the running-apps strip, so the digits + // jump to the Nth listed conversation there (⌘0 being + // the tenth). Sessions are asked first and both handlers + // gate themselves, so only one can claim the chord. + if let row = AppConstants.Launcher.AISessions.row(forJumpDigit: key), + onActivateSession(row) + { + Self.logger.debug("⌘+\(key, privacy: .public) -> session row \(row, privacy: .public)") return nil } - Self.logger.debug( - "⌘+\(key, privacy: .public) running-apps activation declined, falling through" - ) + // The strip badges are 1-9, so 0 addresses no icon and + // falls through to its "Actual Size" menu equivalent. + if key > 0 { + Self.logger.debug("⌘+\(key, privacy: .public) -> running-apps switcher") + if onActivateRunningApp(key) { + return nil + } + Self.logger.debug( + "⌘+\(key, privacy: .public) running-apps activation declined, falling through" + ) + } } } } @@ -311,6 +323,25 @@ final class KeyboardSelectionMonitor { return nil } + // ⌥↑/↓ walks the AI prompt history. It has to sit ABOVE the modifier + // passthrough below, which hands every Option combo to the system. + // Not ⌃↑/↓: those are Mission Control and Application Windows at the + // WindowServer level, so the app never sees them. Not ⇧↑/↓ either - + // the composer is multiline now and needs them to select text. + if event.keyCode == KeyCode.arrowUp || event.keyCode == KeyCode.arrowDown, + flags.contains(.option), + !flags.contains(.command), + !flags.contains(.control), + // ⌥⇧↑/↓ extends the selection by paragraph in the composer. + // Claiming it here would replace the draft with a history entry + // while the user is trying to select text. + !flags.contains(.shift) + { + let older = event.keyCode == KeyCode.arrowUp + if onRecallPrompt?(older) == true { return nil } + return event + } + if event.modifierFlags.contains(.command) || event.modifierFlags.contains(.option) || event.modifierFlags.contains(.control) @@ -396,15 +427,13 @@ final class KeyboardSelectionMonitor { return nil } - // Shift+↑/↓ recalls prompt history in AI mode. The handler returns - // false outside AI mode, so the event falls through to normal - // selection-extension there. - if event.keyCode == KeyCode.arrowUp, flags.contains(.shift) { - if onRecallPrompt?(true) == true { return nil } - return event - } - if event.keyCode == KeyCode.arrowDown, flags.contains(.shift) { - if onRecallPrompt?(false) == true { return nil } + // Shift+↑/↓ belongs to the text field: it extends the selection, and + // in AI mode that is over a composer several lines tall. Passed + // through untouched - the plain-arrow handlers below take no flags + // into account, so without this they would swallow it. + if event.keyCode == KeyCode.arrowUp || event.keyCode == KeyCode.arrowDown, + flags.contains(.shift) + { return event } diff --git a/apps/macos/LauncherApp/look-app/Support/Launcher/LinkRowAppearance.swift b/apps/macos/LauncherApp/look-app/Support/Launcher/LinkRowAppearance.swift new file mode 100644 index 00000000..4295b66a --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Launcher/LinkRowAppearance.swift @@ -0,0 +1,20 @@ +import Foundation + +/// The symbol for a row whose action is "open this URL". Derived from the +/// scheme rather than stored on the row: the id already carries the URL, and a +/// second copy of "what kind of link is this" is a second thing to keep true. +nonisolated enum LinkRowAppearance { + private enum Symbol { + static let message = "message.fill" + static let phone = "phone.fill" + static let video = "video.fill" + } + + static func symbol(forURL url: String) -> String { + let lower = url.lowercased() + if lower.hasPrefix("sms:") || lower.hasPrefix("imessage:") { return Symbol.message } + if lower.hasPrefix("tel:") { return Symbol.phone } + // FaceTime audio and video, and every conferencing link. + return Symbol.video + } +} diff --git a/apps/macos/LauncherApp/look-app/Support/Launcher/PathDisplay.swift b/apps/macos/LauncherApp/look-app/Support/Launcher/PathDisplay.swift new file mode 100644 index 00000000..0757612b --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Launcher/PathDisplay.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Paths as a reader wants to see them. Shared so the mention list, the +/// attachment capsule, and anything else that names a file abbreviate the same +/// way: two files called `main.go` are the normal case, and only the folder +/// tells them apart. +nonisolated enum PathDisplay { + /// `~` for home, so the width goes to the part that identifies the file + /// rather than to `/Users/`. + static func abbreviated(_ path: String) -> String { + let home = NSHomeDirectory() + // On the boundary, not the prefix: with a home of `/Users/alex`, a bare + // prefix test turns `/Users/alexander/notes` into `~ander/notes`. + guard path == home || (home != "/" && path.hasPrefix(home + "/")) else { return path } + return "~" + path.dropFirst(home.count) + } + + /// The containing folder, abbreviated. Empty for a path with no parent. + static func directory(of path: String) -> String { + let parent = (path as NSString).deletingLastPathComponent + return parent.isEmpty ? "" : abbreviated(parent) + } + + static func name(of path: String) -> String { + (path as NSString).lastPathComponent + } +} diff --git a/apps/macos/LauncherApp/look-app/Support/Launcher/SyntheticRow.swift b/apps/macos/LauncherApp/look-app/Support/Launcher/SyntheticRow.swift index 9006ef27..a1da7c66 100644 --- a/apps/macos/LauncherApp/look-app/Support/Launcher/SyntheticRow.swift +++ b/apps/macos/LauncherApp/look-app/Support/Launcher/SyntheticRow.swift @@ -11,6 +11,10 @@ enum SyntheticRow { case calc(raw: String) /// The planner-proposed action row in the main bar (Enter performs it). case aiAction(toolID: String) + /// "Join " for a `join` query (Enter opens the conferencing link). + case meeting(url: String) + /// "Call " for a `call` query (Enter opens FaceTime or Messages). + case call(url: String) static func classify(resultID: String) -> SyntheticRow? { if let toolID = AppConstants.Launcher.AIAction.toolID(fromResultID: resultID) { @@ -31,6 +35,12 @@ enum SyntheticRow { if let raw = AppConstants.Launcher.Calc.rawValue(fromResultID: resultID) { return .calc(raw: raw) } + if let url = AppConstants.Launcher.Meeting.url(fromResultID: resultID) { + return .meeting(url: url) + } + if let url = AppConstants.Launcher.Call.url(fromResultID: resultID) { + return .call(url: url) + } return nil } } diff --git a/apps/macos/LauncherApp/look-app/Support/Launcher/TimedCache.swift b/apps/macos/LauncherApp/look-app/Support/Launcher/TimedCache.swift new file mode 100644 index 00000000..742edfcb --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Support/Launcher/TimedCache.swift @@ -0,0 +1,42 @@ +import Foundation + +/// One value, held for a short while, behind a lock. +/// +/// The launcher's pinned rows are computed properties SwiftUI re-reads on every +/// update, so an EventKit or Contacts lookup behind one needs a cache. One slot, +/// one key, no eviction: a new key is a miss, not a stale hit. +nonisolated final class TimedCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var key: Key? + private var value: Value? + private var storedAt = Date.distantPast + + init(ttl: TimeInterval) { + self.ttl = ttl + } + + /// The cached value for `key`, or `make()` when missing or stale. `make` + /// runs under the lock, so two callers cannot fetch at once. + func value(for key: Key, now: Date = Date(), make: () -> Value) -> Value { + lock.lock() + defer { lock.unlock() } + if let value, self.key == key, now.timeIntervalSince(storedAt) < ttl { + return value + } + let fresh = make() + self.key = key + self.value = fresh + storedAt = now + return fresh + } + + /// Drop what is held. + func invalidate() { + lock.lock() + defer { lock.unlock() } + key = nil + value = nil + storedAt = .distantPast + } +} diff --git a/apps/macos/LauncherApp/look-app/Support/UI/Motion.swift b/apps/macos/LauncherApp/look-app/Support/UI/Motion.swift index 16818786..7defeb5d 100644 --- a/apps/macos/LauncherApp/look-app/Support/UI/Motion.swift +++ b/apps/macos/LauncherApp/look-app/Support/UI/Motion.swift @@ -76,6 +76,33 @@ enum Motion { } } + /// A row landing in or leaving a list: a skipped folder, an extra scan + /// directory. Springs so an entry reads as placed rather than popped in. + enum Insert { + static let response: Double = 0.34 + static let dampingFraction: Double = 0.82 + /// Grown from, and collapsed back to, on the row's leading edge. + static let startScale: CGFloat = 0.9 + + static var animation: Animation { + .spring(response: response, dampingFraction: dampingFraction) + } + + static var transition: AnyTransition { + .scale(scale: startScale, anchor: .leading).combined(with: .opacity) + } + } + + /// Content changing in place rather than moving: the pomo panel dimming to + /// its idle state, a chosen path replacing the empty-state line. + enum Fade { + static let seconds: Double = 0.4 + + static var animation: Animation { + .easeInOut(duration: seconds) + } + } + /// The whole panel arriving when the launcher opens. A content-layer effect /// on purpose: animating the window would mean touching the /// `makeKeyAndOrderFront` path the Cmd+Space cold-login bug lives in. diff --git a/apps/macos/LauncherApp/look-app/Views/Commands/PomoView.swift b/apps/macos/LauncherApp/look-app/Views/Commands/PomoView.swift index 3d7cf8f6..d330bf73 100644 --- a/apps/macos/LauncherApp/look-app/Views/Commands/PomoView.swift +++ b/apps/macos/LauncherApp/look-app/Views/Commands/PomoView.swift @@ -234,7 +234,7 @@ struct PomoView: View { VStack(spacing: 8) { headerBar .opacity(idle ? 0 : 1) - .animation(.easeInOut(duration: 0.4), value: idle) + .animation(Motion.Fade.animation, value: idle) GeometryReader { geo in ScrollView(.vertical, showsIndicators: false) { @@ -243,7 +243,7 @@ struct PomoView: View { timerCard controlsRow .opacity(idle ? 0 : 1) - .animation(.easeInOut(duration: 0.4), value: idle) + .animation(Motion.Fade.animation, value: idle) // Defensive: nothing inside the controls // row should animate. Suppresses any // inherited animation transaction so the @@ -256,7 +256,7 @@ struct PomoView: View { sessionListToggleAndList .opacity(idle ? 0 : 1) - .animation(.easeInOut(duration: 0.4), value: idle) + .animation(Motion.Fade.animation, value: idle) } .frame(minHeight: geo.size.height) } @@ -618,7 +618,9 @@ struct PomoView: View { panel.canChooseDirectories = true panel.canChooseFiles = false if panel.runModal() == .OK, let url = panel.url { - state.music.setFolder(url) + withAnimation(Motion.Fade.animation) { + state.music.setFolder(url) + } } } diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/AttachedFileCapsule.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/AttachedFileCapsule.swift index 7ea5cbe9..8319b0a4 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/AttachedFileCapsule.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/AttachedFileCapsule.swift @@ -11,31 +11,48 @@ struct AttachedFileCapsule: View { @State private var hovering = false - private var name: String { (path as NSString).lastPathComponent } + private var name: String { PathDisplay.name(of: path) } + private var directory: String { PathDisplay.directory(of: path) } var body: some View { Button { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } label: { - HStack(spacing: 4) { + HStack(spacing: 5) { Image(systemName: "doc.text") .font(.system(size: CGFloat(themeStore.settings.fontSize - 4))) - Text(name) - .font( - themeStore.uiFont( - size: CGFloat(themeStore.settings.fontSize - 3), weight: .medium) - ) - .lineLimit(1) + VStack(alignment: .leading, spacing: 0) { + Text(name) + .font( + themeStore.uiFont( + size: CGFloat(themeStore.settings.fontSize - 3), weight: .medium) + ) + .lineLimit(1) + // Which `main.go`. A transcript outlives the moment it was + // written in, and the name alone stops identifying the file + // as soon as a second one shares it. Head-truncated, so the + // folder nearest the file survives. + if !directory.isEmpty { + Text(directory) + .font( + themeStore.uiFont( + size: CGFloat(themeStore.settings.fontSize - 5), weight: .regular) + ) + .foregroundStyle(themeStore.mutedTextColor()) + .lineLimit(1) + .truncationMode(.head) + } + } Image(systemName: "arrow.up.forward.app") .font(.system(size: CGFloat(themeStore.settings.fontSize - 5))) .opacity(hovering ? 0.9 : 0.35) } .foregroundStyle(themeStore.fontColor()) - .padding(.horizontal, 7) - .padding(.vertical, 3) + .padding(.horizontal, 8) + .padding(.vertical, 4) .background( themeStore.accentColor().opacity(hovering ? 0.22 : 0.14), - in: Capsule()) + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(.plain) .onHover { hovering = $0 } diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/ConversationRowView.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/ConversationRowView.swift index 7dd57181..9f818bad 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/ConversationRowView.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/ConversationRowView.swift @@ -7,7 +7,7 @@ import SwiftUI struct ConversationRowView: View { let conversation: AIConversation let snippet: String - /// The ⌘-chip shown on the left ("⌘A"), empty past the mapped keys. + /// The ⌘-chip shown on the left ("⌘1"), empty past the mapped digits. let jumpKey: String let isSelected: Bool let themeStore: ThemeStore @@ -15,13 +15,6 @@ struct ConversationRowView: View { let onOpen: () -> Void let onDelete: () -> Void - /// Drives the one-shot zoom as this row takes the selection. - @State private var zoomed = false - /// Bumped on every zoom and on deselect, so a pending reset belonging to an - /// earlier zoom cannot cut short a newer one (arrow away and back fast). - @State private var zoomGeneration = 0 - @Environment(\.accessibilityReduceMotion) private var reduceMotion - private var fontSize: Double { themeStore.settings.fontSize } var body: some View { @@ -33,7 +26,7 @@ struct ConversationRowView: View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { - Text(conversation.title) + Text(conversation.displayTitle()) .font(themeStore.uiFont(size: CGFloat(fontSize - 1), weight: .medium)) .foregroundStyle(themeStore.fontColor()) .lineLimit(1) @@ -59,7 +52,7 @@ struct ConversationRowView: View { .foregroundStyle(themeStore.mutedTextColor().opacity(isSelected ? 0.9 : 0.35)) } .buttonStyle(.plain) - .help("Delete conversation (⌘⌫)") + .help("Delete conversation (⌘D or ⌘⌫)") } .padding(.horizontal, 10) // Matches the results rows, so the pill is the same height in both lists. @@ -70,33 +63,14 @@ struct ConversationRowView: View { style: .continuous ) .fill(themeStore.surfaceFill(0.55)) - if isSelected { - SelectionPill( - themeStore: themeStore, - namespace: namespace, - geometryID: Self.geometryID, - zoomed: zoomed) - } } + .selectionPill( + isSelected: isSelected, + themeStore: themeStore, + namespace: namespace, + geometryID: Self.geometryID) .contentShape(Rectangle()) .onTapGesture(perform: onOpen) - // No `.animation(_:value:)` here: per-row it fires on every neighbour as - // the selection passes, flickering the whole list. - .onChange(of: isSelected) { _, selected in - guard selected else { - zoomGeneration &+= 1 - zoomed = false - return - } - guard !reduceMotion else { return } - zoomGeneration &+= 1 - let generation = zoomGeneration - withAnimation(Motion.Selection.zoomIn) { zoomed = true } - DispatchQueue.main.asyncAfter(deadline: .now() + Motion.Selection.zoomInSeconds) { - guard generation == zoomGeneration else { return } - withAnimation(Motion.Selection.zoomOut) { zoomed = false } - } - } } /// Its own pill id: the results list has its own, and one pill must never diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/FilePreview.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/FilePreview.swift new file mode 100644 index 00000000..e19e545a --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/FilePreview.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// A file's contents, however it previews best: text and source render in +/// place, everything else goes through Quick Look. Extracted from the result +/// preview pane so the `@`-mention list can show the same thing while picking. +struct FilePreview: View { + let path: String + var maxHeight: CGFloat = .infinity + + var body: some View { + if QuickLookPreviewService.isTextFile(path: path) { + TextFilePreview(path: path, maxHeight: maxHeight) + } else { + QuickLookPreviewImage(path: path, maxHeight: maxHeight) + } + } +} diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherRowView.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherRowView.swift index e91dc9e4..dbf54bfd 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherRowView.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherRowView.swift @@ -4,7 +4,6 @@ import UniformTypeIdentifiers struct LauncherRowView: View { @EnvironmentObject private var themeStore: ThemeStore - let result: LauncherResult let isSelected: Bool let isPicked: Bool @@ -23,29 +22,6 @@ struct LauncherRowView: View { static let dividerOpacity: Double = 0.8 } - /// Drives the one-shot zoom as this row takes the selection. - @State private var zoomed = false - /// Bumped on every zoom and on deselect, so a pending reset that belongs to - /// an earlier zoom cannot cut short a newer one. Reachable by arrowing away - /// and back inside `zoomInSeconds`. - @State private var zoomGeneration = 0 - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private func zoom() { - guard !reduceMotion else { return } - zoomGeneration &+= 1 - let generation = zoomGeneration - withAnimation(Motion.Selection.zoomIn) { - zoomed = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + Motion.Selection.zoomInSeconds) { - guard zoomGeneration == generation else { return } - withAnimation(Motion.Selection.zoomOut) { - zoomed = false - } - } - } - /// Hidden under the selection pill and after the final row. The row keeps /// the divider's height either way, so selection never reflows the list. private var showsDivider: Bool { @@ -72,6 +48,17 @@ struct LauncherRowView: View { NSImage(systemSymbolName: "globe", accessibilityDescription: nil) ?? NSWorkspace.shared.icon(for: .plainText) } + case .meeting: + return RowIconCache.image(key: "symbol:video") { + NSImage(systemSymbolName: "video.fill", accessibilityDescription: nil) + ?? NSWorkspace.shared.icon(for: .plainText) + } + case .call(let url): + let symbol = LinkRowAppearance.symbol(forURL: url) + return RowIconCache.image(key: "symbol:\(symbol)") { + NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + ?? NSWorkspace.shared.icon(for: .plainText) + } case .prefixSuggestion, .webSuggestion: return RowIconCache.image(key: "symbol:magnifyingglass") { NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: nil) @@ -167,10 +154,7 @@ struct LauncherRowView: View { .foregroundStyle(themeStore.selectionFillColor()) .frame(width: 14) } - Image(nsImage: rowIcon) - .resizable() - .frame(width: 22, height: 22) - .scaleEffect(isSelected && zoomed ? Motion.Selection.iconZoomScale : 1) + RowIcon(image: rowIcon, isSelected: isSelected) VStack(alignment: .leading, spacing: 2) { Text(result.title) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize), weight: .medium)) @@ -190,30 +174,12 @@ struct LauncherRowView: View { } .buttonStyle(.plain) .focusable(false) - .background { - // One pill shared across rows via matchedGeometryEffect. It - // glides when the selection change is wrapped in - // `Motion.Selection.glide` (keyboard nav) and snaps otherwise - // (click, results refresh). - if isSelected { - SelectionPill( - themeStore: themeStore, - namespace: selectionNamespace, - zoomed: zoomed) - } - } - // Deliberately no `.animation(_:value:)` in this row: per-row it - // fires on every neighbour as the selection passes, flickering the - // whole list. Clearing on deselect covers LazyVStack recycling, - // where a view can arrive holding a previous row's `zoomed`. - .onChange(of: isSelected) { _, selected in - guard selected else { - zoomGeneration &+= 1 - zoomed = false - return - } - zoom() - } + // Glides when the change is wrapped in `Motion.Selection.glide` + // (keyboard nav), snaps otherwise (click, refresh). + .selectionPill( + isSelected: isSelected, + themeStore: themeStore, + namespace: selectionNamespace) Rectangle() .fill(themeStore.dividerColor().opacity(Layout.dividerOpacity)) @@ -223,3 +189,20 @@ struct LauncherRowView: View { } } } + +/// The row's icon, popping with the selection. +/// +/// Its own view because a view cannot read an environment value its own body +/// sets, and `selectionPill` publishes the zoom from inside `LauncherRowView`. +private struct RowIcon: View { + @Environment(\.isSelectionZoomed) private var zoomed + let image: NSImage + let isSelected: Bool + + var body: some View { + Image(nsImage: image) + .resizable() + .frame(width: 22, height: 22) + .scaleEffect(isSelected && zoomed ? Motion.Selection.iconZoomScale : 1) + } +} diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherSubviews.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherSubviews.swift index 99c07147..343e45f5 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherSubviews.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherSubviews.swift @@ -49,6 +49,9 @@ struct SearchInputBar: View { placeholder: "", isFocused: isQueryFocused, themeStore: themeStore, + // Only the assistant composes prose; a search query with a line + // break in it means nothing to the matcher. + allowsMultiline: isAIMode, onSubmit: onSubmit ) // The field's own placeholder is empty, so it would otherwise @@ -508,19 +511,99 @@ struct RecentEmptyStateView: View { } } +/// Which slice of the help the screen is showing. `all` keeps the original one +/// scroll; the rest narrow it, so arriving from a mode lands on that mode's keys +/// instead of a page the reader has to search. +enum LauncherHelpTopic: CaseIterable, Identifiable { + case all + case main + case ai + case prefixes + case command + + var id: Self { self } + + var label: String { + switch self { + case .all: return "All" + case .main: return "Main" + case .ai: return "AI" + case .prefixes: return "Prefixes" + case .command: return "Command" + } + } + + /// The sections this topic shows, in reading order. + var sections: [LauncherHelpSection] { + switch self { + case .all: + return LauncherHelpTopic.main.sections + + LauncherHelpTopic.ai.sections + + LauncherHelpTopic.prefixes.sections + + LauncherHelpTopic.command.sections + case .main: + return [ + LauncherHelpSection(title: "Main", items: LauncherHelpContent.mainShortcuts), + LauncherHelpSection(title: "Super actions", items: LauncherHelpContent.superActions), + ] + case .ai: + return [LauncherHelpSection(title: "AI mode (>)", items: LauncherHelpContent.aiMode)] + case .prefixes: + return [LauncherHelpSection(title: "Query prefixes", items: LauncherHelpContent.queryModes)] + case .command: + return [LauncherHelpSection(title: "Command mode", items: LauncherHelpContent.commandMode)] + } + } +} + +/// One titled block of key/description pairs. The title is the identity: two +/// sections never share one on the same screen. +struct LauncherHelpSection: Identifiable { + let title: String + let items: [(String, String)] + var id: String { title } +} + struct LauncherHelpScreenView: View { + private enum Metrics { + static let selectedCapsuleOpacity = 0.22 + static let capsuleSpacing: CGFloat = 6 + static let capsuleHorizontalPadding: CGFloat = 10 + static let capsuleVerticalPadding: CGFloat = 4 + /// Wider than the gap between capsules, so the group reads as one unit + /// next to the title rather than a sixth capsule. + static let titleRowSpacing: CGFloat = 12 + } + let themeStore: ThemeStore + /// Where the screen opens. ⌘H from AI mode passes `.ai` so the assistant's + /// keys are the first thing on screen. + var initialTopic: LauncherHelpTopic = .all + + @State private var topic: LauncherHelpTopic + + init(themeStore: ThemeStore, initialTopic: LauncherHelpTopic = .all) { + self.themeStore = themeStore + self.initialTopic = initialTopic + _topic = State(initialValue: initialTopic) + } var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 14) { - HStack { + // The topics ride in the title row rather than owning a band of + // their own: they are navigation for this screen, and a full + // row of them pushed the first shortcut below the fold. + HStack(spacing: Metrics.titleRowSpacing) { Text(LauncherHelpContent.title) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize + 3), weight: .semibold)) - Spacer() + .fixedSize() + topicPicker + Spacer(minLength: 0) Text(LauncherHelpContent.closeHint) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 1), weight: .regular)) .foregroundStyle(themeStore.mutedTextColor()) + .fixedSize() } AppUpdateStatusView(themeStore: themeStore) @@ -529,13 +612,42 @@ struct LauncherHelpScreenView: View { .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize), weight: .regular)) .foregroundStyle(themeStore.secondaryTextColor()) - ShortcutHelpSection(title: "Main", items: LauncherHelpContent.mainShortcuts) - ShortcutHelpSection(title: "Super actions", items: LauncherHelpContent.superActions) - ShortcutHelpSection(title: "Query prefixes", items: LauncherHelpContent.queryModes) - ShortcutHelpSection(title: "Command mode", items: LauncherHelpContent.commandMode) + ForEach(topic.sections) { section in + ShortcutHelpSection(title: section.title, items: section.items) + } } .padding(12) } + // The screen is rebuilt on each open, but a reused instance would keep + // the last topic and ignore where the reader came from. + .onChange(of: initialTopic) { _, requested in topic = requested } + } + + private var topicPicker: some View { + HStack(spacing: Metrics.capsuleSpacing) { + ForEach(LauncherHelpTopic.allCases) { candidate in + let isSelected = candidate == topic + Button { topic = candidate } label: { + Text(candidate.label) + .font(themeStore.uiFont( + size: CGFloat(themeStore.settings.fontSize - 1), + weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? themeStore.fontColor() : themeStore.mutedTextColor()) + .padding(.horizontal, Metrics.capsuleHorizontalPadding) + .padding(.vertical, Metrics.capsuleVerticalPadding) + .background( + isSelected + ? themeStore.accentColor().opacity(Metrics.selectedCapsuleOpacity) + : themeStore.controlFillColor(), + in: Capsule()) + } + .buttonStyle(.plain) + .help("Show \(candidate.label) shortcuts") + } + } + // Sits between the title and the close hint, so the capsules keep their + // own width instead of being squeezed by the row. + .fixedSize() } } @@ -563,6 +675,28 @@ private enum LauncherHelpContent { ("Esc", "Close help / back / hide launcher"), ] + // The `>` assistant: the sessions list, a live conversation, and the keys + // that only exist there (the running-apps strip is hidden in this mode, so + // Cmd+digit addresses conversations instead of apps). + static let aiMode: [(String, String)] = [ + (">", "Enter AI mode (a dead-end Enter on the home screen goes here too)"), + ("Enter", "Send the message, or open the highlighted conversation"), + ("Shift+Enter", "New line in the message (the box grows to 6 lines)"), + ("Option+Up / Option+Down", "Walk your recent prompts, like a shell history"), + ("Shift+Up / Shift+Down", "Select text in the message you are composing"), + ("Cmd+1..Cmd+9, Cmd+0", "Open the conversation carrying that chip (Cmd+0 is the tenth)"), + ("Tab / Up / Down", "Move over the conversation list"), + ("Cmd+D", "Delete the highlighted conversation"), + ("Cmd+Z", "Undo the last action, or restore a just-deleted conversation"), + ("Cmd+.", "Stop a streaming answer"), + ("@name", "Attach a file to the message (Enter picks the highlighted one)"), + ("@ 5pm", "Set an exact time on an event or reminder"), + ("1, 2, 3 + Enter", "Answer a \u{201C}which one?\u{201D} list"), + ("Cmd+H", "Open this help without leaving the conversation"), + ("Esc", "Close the file popup, then leave the conversation"), + ("Shift+Esc", "Leave AI mode straight to the home screen"), + ] + // The strip on the empty home screen. Keys are the tile mnemonics from the // shared catalog (core/qactions), fired with Cmd. static let superActions: [(String, String)] = [ diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Calling.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Calling.swift new file mode 100644 index 00000000..d65253af --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Calling.swift @@ -0,0 +1,77 @@ +import Foundation + +/// The pinned "Call " rows. The grammar ("is this a call request?") and +/// the URL each modality needs live in the shared `core/ai` crate via +/// `EngineBridge`; Contacts is the platform's. This file is presentation and +/// placement only. Mirrors `LauncherView+Meeting.swift`. +extension LauncherView { + private enum Copy { + static let enterHint = "Enter to call" + static let enterMessageHint = "Enter to message" + /// A main-bar row per way to reach someone, but not a screenful: past + /// this the query should say which person or which number. + static let rowLimit = 5 + } + + /// Rows for `call`-style queries, best first, or empty. Unlike the meeting + /// row there can be several - Mom's mobile and her work number are both + /// answers - and the results list is already a list, so they go in as rows + /// rather than behind a picker. + /// + /// The Contacts read behind this is cached (see `ContactsService`), so it + /// is safe to evaluate on every keystroke like the other pinned rows. + var callResults: [LauncherResult] { + guard allowsSuggestionRows, let request = bridge.callQuery(query) else { return [] } + // A name that matches nobody shows no row at all, which is what keeps + // "call stack" an ordinary file search. + let wanted = request.modality ?? bridge.defaultCallModality + let matches = ContactsService.shared.matches(name: request.name) + + // Deduped by id, which is the URL: a number shared by two contacts (a + // family landline) would otherwise be two rows that do exactly the + // same thing, with the same id - and a duplicate id breaks row + // identity in the results list. + var seen = Set() + return matches.flatMap { match in + match.handles + .filter { $0.modalityID == wanted } + .compactMap { handle in row(match: match, handle: handle) } + } + .filter { seen.insert($0.id).inserted } + .prefix(Copy.rowLimit) + .enumerated() + .map { index, result in + var ranked = result + // Descending from `.max` keeps the first row above the calc row and + // the rest in the order Contacts gave them. + ranked.score = Int.max - index + return ranked + } + } + + private func row(match: ContactMatch, handle: ContactHandle) -> LauncherResult? { + guard let url = bridge.callURL(modality: handle.modalityID, handle: handle.handle) else { + return nil + } + let isMessage = handle.modalityID == "message" + var detail = [handle.modalityLabel] + if let label = handle.handleLabel, !label.isEmpty { detail.append(label) } + detail.append(handle.handle) + detail.append(isMessage ? Copy.enterMessageHint : Copy.enterHint) + + var result = LauncherResult( + id: AppConstants.Launcher.Call.resultID(url: url), + kind: .app, + title: "\(isMessage ? "Message" : "Call") \(match.name)", + subtitle: detail.joined(separator: " • "), + // No path: the row opens a URL, and there is no file behind it. + path: "", + score: .max + ) + result.linkKindLabel = handle.modalityLabel + result.linkDetail = [handle.handleLabel, handle.handle] + .compactMap { $0 } + .joined(separator: " · ") + return result + } +} diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+CommandMode.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+CommandMode.swift index b7884d91..32399e6e 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+CommandMode.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+CommandMode.swift @@ -115,7 +115,10 @@ extension LauncherView { actionController.submitExplicitAIQuery(text) clearQuerySilently() clearAttachments() - DispatchQueue.main.async { isQueryFocused = true } + // The whole panel below the bar swaps (results list -> AI session), so + // a single `isQueryFocused = true` can land before the layout settles. + // focusActiveInput retries and also sets first responder in AppKit. + focusActiveInput(activateApp: false) } /// Clears the input without triggering the AI side effects of the query @@ -153,6 +156,24 @@ extension LauncherView { // first, then planner/chat). let submitTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) if !isCommandMode, isAIMode { + // The picker takes Enter: a bare Enter joins the highlighted + // row, a typed number picks that one. Before the message path, so + // "1" answers the list rather than becoming a new question. + if actionController.linkPicker != nil { + if submitTrimmed.isEmpty { + openHighlightedLink() + DispatchQueue.main.async { isQueryFocused = true } + return + } + if let number = Int(submitTrimmed), actionController.selectPickerRow(number: number) { + openHighlightedLink() + DispatchQueue.main.async { isQueryFocused = true } + return + } + // Anything else typed is a new request, so the list stops being + // the answer and the message path below takes over. + actionController.clearPicker() + } if let choice = actionController.pendingChoice, let number = Int(submitTrimmed), number >= 1, number <= choice.candidates.count { @@ -162,7 +183,7 @@ extension LauncherView { selectedConversationIndex >= 0, selectedConversationIndex < filteredConversations.count { // A highlighted session opens; otherwise Enter starts a new chat. - chat.continueConversation(filteredConversations[selectedConversationIndex]) + openConversation(filteredConversations[selectedConversationIndex]) clearQuerySilently() } else if !submitTrimmed.isEmpty { // Routing (incl. file-recall detection) lives in the Rust-core diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+LinkPicker.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+LinkPicker.swift new file mode 100644 index 00000000..b64a281c --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+LinkPicker.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// The list a `join` or `call` puts on the AI panel: rows to open, one of them +/// highlighted. Presentation only - which rows exist, and what opening one +/// does, belong to `ActionController+Links`. +extension LauncherView { + /// The rows a `join` or `call` turned up. It always lists when there is + /// any choice at all: the point is to see WHICH meeting, or WHICH way to + /// reach someone, before a link opens. Tab/arrows move, Enter opens, a + /// number picks directly. + @ViewBuilder + func linkPickerList(_ picker: ActionController.LinkPicker) -> some View { + let fontSize = themeStore.settings.fontSize + VStack(alignment: .leading, spacing: 6) { + Text("\(picker.header) · Tab to move · Enter opens · Esc cancels") + .font(themeStore.uiFont(size: CGFloat(fontSize - 3), weight: .semibold)) + .foregroundStyle(themeStore.mutedTextColor()) + + ForEach(Array(picker.options.enumerated()), id: \.element.id) { index, option in + Button { + actionController.selectPickerRow(number: index + 1) + openHighlightedLink() + } label: { + HStack(spacing: 10) { + Text("\(index + 1)") + .font(themeStore.uiFont(size: CGFloat(fontSize - 2), weight: .semibold)) + .foregroundStyle(themeStore.accentColor()) + .frame(minWidth: 14, alignment: .leading) + Image(systemName: option.symbol) + .font(.system(size: CGFloat(fontSize - 3))) + .foregroundStyle(themeStore.accentColor()) + VStack(alignment: .leading, spacing: 1) { + Text(option.title) + .font(themeStore.uiFont(size: CGFloat(fontSize - 1), weight: .medium)) + .foregroundStyle(themeStore.fontColor()) + .lineLimit(1) + // What the row is actually promising. Opening + // without showing this is what the first version + // got wrong. + Text(option.detail) + .font(themeStore.uiFont(size: CGFloat(fontSize - 3), weight: .regular)) + .foregroundStyle(themeStore.mutedTextColor()) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background { + RoundedRectangle(cornerRadius: SelectionPill.Metrics.cornerRadius, style: .continuous) + .fill(themeStore.surfaceFill(0.55)) + } + .selectionPill( + isSelected: index == picker.selected, + themeStore: themeStore, + namespace: linkPickerNamespace, + geometryID: Self.linkPickerPillID) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + } + + /// Its own pill id: two lists must never share one, or the pill flies + /// between them. + static let linkPickerPillID = "look.linkpicker.pill" +} diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Meeting.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Meeting.swift new file mode 100644 index 00000000..e1a34e22 --- /dev/null +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Meeting.swift @@ -0,0 +1,56 @@ +import Foundation + +/// The pinned "Join " row. The grammar ("is this a join request?"), +/// the link hiding in the invite, and the choice of WHICH meeting all live in +/// the shared `core/ai` crate via `EngineBridge` and `MeetingService`, so this +/// file is presentation and placement only. Mirrors `LauncherView+Calc.swift`. +extension LauncherView { + private enum Copy { + static let enterHint = "Enter to join" + } + + /// Open the highlighted picker row and get out of the way. The meeting app, + /// the browser, or FaceTime is taking the screen, so a launcher left open on + /// a status bar is a dead end the user has to Esc out of. A failure keeps + /// the panel up, since that is the case with something to read. + func openHighlightedLink() { + guard actionController.openSelectedLink() else { return } + clearQuerySilently() + hideLauncherWindow(restorePreviousApp: false) + } + + /// A synthesized row for `join`-style queries, or nil. The calendar read + /// behind this is cached (see `MeetingService`), so it is safe to evaluate + /// on every keystroke like the other pinned rows. + var meetingResult: LauncherResult? { + guard allowsSuggestionRows, let request = bridge.joinQuery(query) else { return nil } + // A named request that matches nothing shows no row at all, which is + // what keeps "join two pdfs" an ordinary file search. + guard let meeting = MeetingService.shared.nextMeeting(name: request.name ?? "") else { + return nil + } + + let timing = MeetingTiming.phrase(meeting) + var result = LauncherResult( + id: AppConstants.Launcher.Meeting.resultID(url: meeting.url), + kind: .app, + title: "Join \(meeting.title)", + subtitle: "\(meeting.providerLabel) • \(timing) • \(Copy.enterHint)", + // No path: the row opens a URL, and there is no file to reveal or + // preview behind it. The icon comes from the synthetic-row symbol. + path: "", + score: .max + ) + result.linkKindLabel = meeting.providerLabel + result.linkDetail = Self.detail(meeting, timing: timing) + return result + } + + /// The preview pane's line. A countdown is paired with the clock time it is + /// counting to; a timing that already names the time is left alone, so the + /// pane never reads "14:30 · tomorrow 14:30". + private static func detail(_ meeting: JoinableMeeting, timing: String) -> String { + let clock = MeetingTiming.clockTime.string(from: meeting.startDate) + return timing.contains(clock) ? timing : "\(clock) · \(timing)" + } +} diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Mentions.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Mentions.swift index 4602c158..87bbb691 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Mentions.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Mentions.swift @@ -57,12 +57,9 @@ extension LauncherView { nonisolated static let mentionLimit = 6 - /// `~` for home, so a path stays readable at this size instead of spending - /// its width on `/Users/`. - nonisolated static func displayPath(_ path: String) -> String { - let home = NSHomeDirectory() - return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path - } + /// The mention list's own pill id. One per list: sharing an id with another + /// list on screen would make the pill fly between the two. + nonisolated static let mentionPillID = "look.mention.pill" /// Tab / Shift-Tab (and the arrows) roll the list. Returns false when there /// is no popup, so the caller falls through to normal selection. @@ -70,10 +67,17 @@ extension LauncherView { func moveMentionHighlight(forward: Bool) -> Bool { guard showsMentionPopup else { return false } let count = mentionMatches.count + let next: Int if forward { - mentionHighlight = mentionHighlight >= count - 1 ? 0 : mentionHighlight + 1 + next = mentionHighlight >= count - 1 ? 0 : mentionHighlight + 1 } else { - mentionHighlight = mentionHighlight <= 0 ? count - 1 : mentionHighlight - 1 + next = mentionHighlight <= 0 ? count - 1 : mentionHighlight - 1 + } + // Same curve as the results and session lists: the animation is what + // makes the shared pill glide instead of jump. Only keyboard moves are + // wrapped - a click or a fresh search should snap. + withAnimation(Motion.Selection.glide) { + mentionHighlight = next } return true } @@ -176,54 +180,35 @@ extension LauncherView { } } - /// The suggestion list. Name plus folder, because two files with the same - /// name is the normal case, not the edge case. + private enum PopupMetrics { + /// Tall enough that the preview shows real content, not a teaser: a + /// source file needs a screenful before it identifies itself. Fixed, so + /// the panel below does not jump as matches come and go. + static let height: CGFloat = 380 + static let columnGap: CGFloat = 8 + static let dividerWidth: CGFloat = 1 + } + + /// The suggestion list beside a preview of the highlighted file. Two files + /// with the same name is the normal case (six `main.go`s is a real result), + /// so the path is on every row and the contents are one Tab away. @ViewBuilder var mentionPopup: some View { if showsMentionPopup { - let fontSize = themeStore.settings.fontSize - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(mentionMatches.enumerated()), id: \.element.id) { index, file in - Button { - mentionHighlight = index - acceptHighlightedMention() - } label: { - HStack(spacing: 8) { - Image(systemName: "doc.text") - .font(.system(size: CGFloat(fontSize - 3))) - .foregroundStyle(themeStore.accentColor()) - VStack(alignment: .leading, spacing: 0) { - Text(file.title) - .font(themeStore.uiFont(size: CGFloat(fontSize - 2), weight: .medium)) - .foregroundStyle(themeStore.fontColor()) - .lineLimit(1) - // The full path, not `subtitle`: two files with - // the same name is the normal case, and only the - // path tells them apart. Truncated at the HEAD so - // the folder nearest the file stays readable. - Text(Self.displayPath(file.path)) - .font(themeStore.uiFont(size: CGFloat(fontSize - 4), weight: .regular)) - .foregroundStyle(themeStore.mutedTextColor()) - .lineLimit(1) - .truncationMode(.head) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background( - index == mentionHighlight - ? themeStore.selectionFillColor() : Color.clear, - in: RoundedRectangle(cornerRadius: 6, style: .continuous)) - } - .buttonStyle(.plain) - } - Text("Tab to pick · Enter to attach · Esc to dismiss") - .font(themeStore.uiFont(size: CGFloat(fontSize - 4), weight: .regular)) - .foregroundStyle(themeStore.mutedTextColor().opacity(0.8)) - .padding(.horizontal, 8) - .padding(.top, 2) + // Even halves: the paths and the contents are equally the thing the + // reader is comparing, so neither column gets to be the sidebar. + HStack(alignment: .top, spacing: PopupMetrics.columnGap) { + mentionList + .frame(maxWidth: .infinity, alignment: .topLeading) + + Rectangle() + .fill(themeStore.dividerColor()) + .frame(width: PopupMetrics.dividerWidth) + + mentionPreviewColumn + .frame(maxWidth: .infinity, alignment: .topLeading) } + .frame(height: PopupMetrics.height) .padding(4) .background( themeStore.surfaceFill(0.92), @@ -231,4 +216,87 @@ extension LauncherView { .padding(.horizontal, 4) } } + + private var mentionList: some View { + let fontSize = themeStore.settings.fontSize + return VStack(alignment: .leading, spacing: 1) { + ForEach(Array(mentionMatches.enumerated()), id: \.element.id) { index, file in + Button { + mentionHighlight = index + acceptHighlightedMention() + } label: { + HStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: CGFloat(fontSize - 3))) + .foregroundStyle(themeStore.accentColor()) + VStack(alignment: .leading, spacing: 0) { + Text(file.title) + .font(themeStore.uiFont(size: CGFloat(fontSize - 2), weight: .medium)) + .foregroundStyle(themeStore.fontColor()) + .lineLimit(1) + // The full path, not `subtitle`: two files with + // the same name is the normal case, and only the + // path tells them apart. Truncated at the HEAD so + // the folder nearest the file stays readable. + Text(PathDisplay.abbreviated(file.path)) + .font(themeStore.uiFont(size: CGFloat(fontSize - 4), weight: .regular)) + .foregroundStyle(themeStore.mutedTextColor()) + .lineLimit(1) + .truncationMode(.head) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + // The shared pill and zoom, so Tab here moves exactly as it + // does in the results and session lists. + .selectionPill( + isSelected: index == mentionHighlight, + themeStore: themeStore, + namespace: mentionSelectionNamespace, + geometryID: Self.mentionPillID) + } + .buttonStyle(.plain) + } + + Spacer(minLength: 0) + + Text("Tab to pick · Enter to attach · Esc to dismiss") + .font(themeStore.uiFont(size: CGFloat(fontSize - 4), weight: .regular)) + .foregroundStyle(themeStore.mutedTextColor().opacity(0.8)) + .padding(.horizontal, 8) + .padding(.top, 2) + } + } + + /// The highlighted file's contents. Deliberately follows the HIGHLIGHT and + /// not the top match: with nothing highlighted Enter still sends the + /// message, and previewing a file the keyboard is not pointing at would + /// suggest otherwise. + @ViewBuilder + private var mentionPreviewColumn: some View { + if mentionHighlight >= 0, mentionHighlight < mentionMatches.count { + let file = mentionMatches[mentionHighlight] + VStack(alignment: .leading, spacing: 4) { + Text(PathDisplay.directory(of: file.path)) + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 4), weight: .regular)) + .foregroundStyle(themeStore.mutedTextColor()) + .lineLimit(1) + .truncationMode(.head) + FilePreview(path: file.path) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + } else { + VStack { + Spacer(minLength: 0) + Text("Tab to preview") + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 3), weight: .regular)) + .foregroundStyle(themeStore.mutedTextColor().opacity(0.7)) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + } } diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Results.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Results.swift index 69364421..99d5af66 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Results.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Results.swift @@ -47,6 +47,13 @@ extension LauncherView { guard let planned = mainBarAction else { return } runQuickAction(planned) return + case .meeting(let url), .call(let url): + // Join and call rows: the link travelled in the row id, so pressing + // Enter never re-reads the calendar or Contacts, and can never open + // something other than what the row named. + openURLScheme(url) + hideLauncherWindow(restorePreviousApp: false) + return case nil: break } @@ -351,15 +358,26 @@ extension LauncherView { return } showsHelpScreen.toggle() + if !showsHelpScreen { restoreFocusAfterHelp() } } @discardableResult func dismissHelpIfVisible() -> Bool { guard showsHelpScreen else { return false } showsHelpScreen = false + restoreFocusAfterHelp() return true } + /// Put the caret back in the search field after help closes. The top row is + /// dropped entirely while help is up, so the field is a NEW view by the time + /// we return and setting `isQueryFocused` alone lands on nothing: the staged + /// recovery delays are what wait for it to exist. Not `activateApp`, the + /// launcher is already frontmost. + private func restoreFocusAfterHelp() { + focusActiveInput(activateApp: false) + } + func deleteClipboardResult(resultID: String) { guard let entryID = LauncherClipboardFeature.entryID(fromResultID: resultID) else { return } clipboardStore.deleteEntry(id: entryID) diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Selection.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Selection.swift index 79a3cf56..aee363e1 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Selection.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/LauncherView+Selection.swift @@ -32,6 +32,18 @@ extension LauncherView { return } + // The picker owns Tab while it is up: it is the only thing on the + // panel, and Enter is about to open one of its rows. Wrapped in the + // shared curve, like every other list, or its pill would jump while the + // rest glide. + if direction == .down || direction == .up { + var moved = false + withAnimation(Motion.Selection.glide) { + moved = actionController.movePickerSelection(forward: direction == .down) + } + if moved { return } + } + // Sessions list: Tab/Shift-Tab and ↑/↓ move the highlight over // [-1 = new chat, 0..` owns the whole panel area, like translation and clipboard // do: the session screen holds completed actions, the pending @@ -1205,8 +1236,6 @@ struct LauncherView: View { themeStore: themeStore ) } - } else if showsHelpScreen { - LauncherHelpScreenView(themeStore: themeStore) } else if isClipboardQuery && displayedResults.isEmpty { // The empty clipboard screen is naturally two columns (history / // how-to), so float it as the same two-card grid as the results. @@ -1278,6 +1307,11 @@ struct LauncherView: View { Text(message) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 1), weight: .semibold)) .foregroundStyle(themeStore.fontColor()) + // A banner is one line inside a capsule. Anything longer is + // truncated by its caller; without this a stray newline in an + // interpolated title stacks the pill into a tall narrow block. + .lineLimit(1) + .truncationMode(.middle) if let copyText = bannerCopyText { Button("Copy") { NSPasteboard.general.clearContents() @@ -1309,16 +1343,19 @@ struct LauncherView: View { } /// Stored conversations matching the typed text (title or content), for the - /// browse list shown while no conversation is active. Capped at 9 so the - /// "number + Enter continues" affordance stays unambiguous. + /// browse list shown while no conversation is active. Capped at exactly as + /// many rows as there are ⌘-digit chips, so every listed row is reachable by + /// its chip and no row is listed without one. More conversations than that + /// are stored and stay findable by typing. var filteredConversations: [AIConversation] { let term = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let all = conversationCache - guard !term.isEmpty, Int(term) == nil else { return Array(all.prefix(9)) } + let limit = AppConstants.Launcher.AISessions.jumpKeyLimit + guard !term.isEmpty, Int(term) == nil else { return Array(all.prefix(limit)) } return Array(all.filter { convo in convo.title.lowercased().contains(term) || convo.items.contains { $0.text.lowercased().contains(term) } - }.prefix(9)) + }.prefix(limit)) } /// AI compose text (the input with `>` already consumed on entry). @@ -1333,6 +1370,7 @@ struct LauncherView: View { && chat.sessionItems.isEmpty && !actionController.isPresenting && actionController.pendingChoice == nil + && actionController.linkPicker == nil && actionController.feedback.isEmpty } @@ -1346,18 +1384,23 @@ struct LauncherView: View { return flat.count > 200 ? String(flat.prefix(200)) + "…" : flat } - /// Ergonomic home-row jump keys for the sessions list (⌘A, ⌘S, ⌘D, …). - /// The order MUST match the monitor's "asdfghjkl". - static let sessionJumpKeys: [Character] = Array("asdfghjkl") + /// The chip for row `index` ("⌘1" … "⌘9", "⌘0" for the tenth), empty past + /// the mapped rows. The digits are free in AI mode because it hides the + /// running-apps strip that owns them everywhere else. + static func sessionJumpKey(at index: Int) -> String { + guard let digit = AppConstants.Launcher.AISessions.jumpDigit(forRow: index) else { + return "" + } + return "⌘\(digit)" + } - /// `⌘`+home-row jump: open the Nth listed conversation. Only while browsing; + /// `⌘`+digit jump: open the Nth listed conversation. Only while browsing; /// returns false so the chord falls through otherwise. func openSessionAt(_ index: Int) -> Bool { guard isBrowsingConversations, index >= 0, index < filteredConversations.count else { return false } - chat.continueConversation(filteredConversations[index]) - query = "" + openConversation(filteredConversations[index]) return true } @@ -1385,7 +1428,7 @@ struct LauncherView: View { } deletedConversation = convo showBanner( - "Deleted \u{201C}\(convo.title.prefix(32))\u{201D} · ⌘Z undo", + "Deleted \u{201C}\(convo.displayTitle(limit: Self.bannerTitleLimit))\u{201D} · ⌘Z undo", duration: 6.0) } @@ -1396,7 +1439,7 @@ struct LauncherView: View { deletedConversation = nil ConversationStore.upsert(convo) conversationCache = ConversationStore.load() - showBanner("Restored \u{201C}\(convo.title.prefix(32))\u{201D}") + showBanner("Restored \u{201C}\(convo.displayTitle(limit: Self.bannerTitleLimit))\u{201D}") return true } @@ -1412,10 +1455,10 @@ struct LauncherView: View { promptHistoryIndex = nil } - /// ↑/↓ in an open chat walk the prompt history like a shell: ↑ older, ↓ + /// ⌥↑/⌥↓ in an open chat walk the prompt history like a shell: ↑ older, ↓ /// newer, ↓ past the end returns to the empty input. Returns whether it - /// acted, so a no-op (empty history, past a boundary) lets the key fall - /// through to normal text-selection extension. + /// moved; in AI mode the caller consumes the chord either way, so a boundary + /// press does nothing rather than reaching the composer. @discardableResult func recallPrompt(_ direction: MoveCommandDirection) -> Bool { guard !aiPromptHistory.isEmpty else { return false } @@ -1447,12 +1490,42 @@ struct LauncherView: View { query = text } - /// ⌘⌫ deletes the highlighted session (no-op when nothing is highlighted). + /// The footer keys, matched to what is actually on screen: the browse list + /// has chords a live conversation does not, and a streaming answer can be + /// stopped. One line either way, so the panel height never shifts. + var sessionFooterHint: String { + if chat.isStreamingAnswer { + return "⌘. stop · Esc leave · ⌘Z undo" + } + if isBrowsingConversations, !filteredConversations.isEmpty { + return "⌘1-9 ⌘0 open · ⌘D delete · ⌘H help · Esc leave" + } + return "⇧↵ new line · Esc leave · ⌘Z undo · @ sets exact time" + } + + /// ⌘D and ⌘⌫ delete the highlighted session (no-op when nothing is + /// highlighted). + /// + /// Guarded HERE rather than at each chord, so every entry point inherits + /// it: with a conversation open there is no list on screen, and the index + /// left over from the row the user opened is not a delete target. Deleting + /// the conversation you are reading, from a list you cannot see, was the + /// bug this prevents. func deleteHighlightedSession() { + guard isBrowsingConversations else { return } guard selectedConversationIndex >= 0, selectedConversationIndex < filteredConversations.count else { return } deleteConversation(filteredConversations[selectedConversationIndex]) } + /// Open a stored conversation. Clears the list highlight with it: the row + /// is no longer a selection once its transcript is on screen, and a stale + /// index is what let a delete chord reach it. + func openConversation(_ conversation: AIConversation) { + chat.continueConversation(conversation) + selectedConversationIndex = -1 + query = "" + } + /// The AI session screen: actions, questions, and streaming answers stack in /// one scrolling conversation; the pending confirm or progress sits below the /// stack, and a footer teaches the keys. Enter runs, Esc leaves, the session @@ -1465,7 +1538,7 @@ struct LauncherView: View { .keyboardShortcut(.escape, modifiers: .shift) Button("") { deleteHighlightedSession() } .keyboardShortcut(.delete, modifiers: .command) - .disabled(selectedConversationIndex < 0 || filteredConversations.isEmpty) + .disabled(!isBrowsingConversations || selectedConversationIndex < 0) } .buttonStyle(.plain) .opacity(0) @@ -1515,6 +1588,8 @@ struct LauncherView: View { } } .padding(.horizontal, 4) + } else if let picker = actionController.linkPicker { + linkPickerList(picker) } else if !actionController.pendingSteps.isEmpty { PendingActionBar( steps: actionController.pendingSteps, @@ -1550,16 +1625,11 @@ struct LauncherView: View { ConversationRowView( conversation: convo, snippet: conversationSnippet(convo), - jumpKey: index < Self.sessionJumpKeys.count - ? "⌘\(String(Self.sessionJumpKeys[index]).uppercased())" - : "", + jumpKey: Self.sessionJumpKey(at: index), isSelected: selectedConversationIndex == index, themeStore: themeStore, namespace: conversationSelectionNamespace, - onOpen: { - chat.continueConversation(convo) - query = "" - }, + onOpen: { openConversation(convo) }, onDelete: { deleteConversation(convo) }) .id(convo.id) } @@ -1615,9 +1685,7 @@ struct LauncherView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - Text(chat.isStreamingAnswer - ? "⌘. stop · Esc leave · ⌘Z undo" - : "Esc leave · ⌘Z undo · @ sets exact time") + Text(sessionFooterHint) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 3), weight: .regular)) .foregroundStyle(themeStore.mutedTextColor()) .padding(.horizontal, 4) diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/ResultPreviewView.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/ResultPreviewView.swift index 2e64e3ff..2f0098d8 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/ResultPreviewView.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/ResultPreviewView.swift @@ -110,9 +110,9 @@ struct ResultPreviewView: View { KindBadge(kind: look.typeName.lowercased()) VStack(alignment: .leading, spacing: 8) { - aiActionHintRow(key: "↵", text: look.verb) - aiActionHintRow(key: "⌘Z", text: "Undo after it runs") - aiActionHintRow(key: "Esc", text: "Dismiss") + hintRow(key: "↵", text: look.verb) + hintRow(key: "⌘Z", text: "Undo after it runs") + hintRow(key: "Esc", text: "Dismiss") } .padding(.top, 6) @@ -122,7 +122,89 @@ struct ResultPreviewView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func aiActionHintRow(key: String, text: String) -> some View { + /// The synthesized rows that open a URL - a meeting to join, a way to + /// reach someone. No file behind either, so they take the same centered + /// hero shape as the action and calc rows. + private var linkURL: String? { + switch SyntheticRow.classify(resultID: result.id) { + case .meeting(let url), .call(let url): return url + default: return nil + } + } + + private func linkIcon(_ url: String) -> NSImage { + NSImage( + systemSymbolName: LinkRowAppearance.symbol(forURL: url), accessibilityDescription: nil) + ?? NSWorkspace.shared.icon(for: .plainText) + } + + /// "Teams · 14:30 · in 4 min", or "FaceTime audio · mobile · +1 …", + /// dropping whichever half is missing. + private var linkDetailLine: String? { + let parts = [result.linkKindLabel, result.linkDetail].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func linkPreview(_ url: String) -> some View { + VStack(spacing: 14) { + Spacer(minLength: 0) + + Image(nsImage: linkIcon(url)) + .resizable() + .scaledToFit() + .frame(width: 52, height: 52) + .foregroundStyle(themeStore.accentColor()) + + Text(result.title) + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize + 5), weight: .bold)) + .foregroundStyle(themeStore.fontColor()) + .multilineTextAlignment(.center) + .lineLimit(3) + .minimumScaleFactor(0.6) + + // Not a `KindBadge`: it renders `kind.capitalized`, which would turn + // "GoToMeeting" into "Gotomeeting". Provider names are the one label + // here whose casing is the brand. + if let detail = linkDetailLine { + Text(detail) + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize), weight: .medium)) + .foregroundStyle(themeStore.mutedTextColor()) + .multilineTextAlignment(.center) + } + + VStack(alignment: .leading, spacing: 8) { + hintRow(key: "↵", text: openHint(url)) + hintRow(key: "Esc", text: "Dismiss") + } + .padding(.top, 6) + + // Where Enter actually goes. An invite is written by whoever sent + // it, so naming the host is the one thing that lets a reader catch + // a link that is not the meeting it claims to be. + if let host = URL(string: url)?.host { + Text(host) + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 3), weight: .regular)) + .foregroundStyle(themeStore.secondaryTextColor()) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 0) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + /// What Enter will actually do, in the words of the destination. + private func openHint(_ url: String) -> String { + let lower = url.lowercased() + if lower.hasPrefix("sms:") || lower.hasPrefix("imessage:") { return "Open Messages" } + if lower.hasPrefix("tel:") { return "Call through your iPhone" } + if lower.hasPrefix("facetime") { return "Start the FaceTime call" } + return "Join the meeting" + } + + private func hintRow(key: String, text: String) -> some View { HStack(spacing: 10) { Text(key) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 2), weight: .semibold)) @@ -235,6 +317,8 @@ struct ResultPreviewView: View { calcPreview } else if let toolID = aiActionToolID { aiActionPreview(toolID) + } else if let linkURL { + linkPreview(linkURL) } else { let info = bundleInfo @@ -291,11 +375,7 @@ struct ResultPreviewView: View { } if result.kind == .file { - if QuickLookPreviewService.isTextFile(path: result.path) { - TextFilePreview(path: result.path, maxHeight: .infinity) - } else { - QuickLookPreviewImage(path: result.path, maxHeight: .infinity) - } + FilePreview(path: result.path) } if result.kind == .folder { diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/SelectionPill.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/SelectionPill.swift index ed41adc0..d5bcc135 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/SelectionPill.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/SelectionPill.swift @@ -37,3 +37,81 @@ struct SelectionPill: View { .scaleEffect(zoomed ? Motion.Selection.pillZoomScale : 1) } } + +/// How a row shows selection: the shared pill plus the one-shot zoom. A +/// `ViewModifier` because the zoom needs `@State`, which an inline `ForEach` +/// body cannot hold. +private struct SelectionPillModifier: ViewModifier { + let isSelected: Bool + let themeStore: ThemeStore + let namespace: Namespace.ID + let geometryID: String + + @State private var zoomed = false + /// Bumped on every zoom and on deselect, so a pending reset belonging to an + /// earlier zoom cannot cut short a newer one (arrow away and back fast). + @State private var generation = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + // Published downward so a row's own content can move with it. + .environment(\.isSelectionZoomed, zoomed) + .background { + if isSelected { + SelectionPill( + themeStore: themeStore, + namespace: namespace, + geometryID: geometryID, + zoomed: zoomed) + } + } + // No `.animation(_:value:)` here: per-row it fires on every + // neighbour as the selection passes, flickering the whole list. + .onChange(of: isSelected) { _, selected in + guard selected else { + generation &+= 1 + zoomed = false + return + } + guard !reduceMotion else { return } + generation &+= 1 + let mine = generation + withAnimation(Motion.Selection.zoomIn) { zoomed = true } + DispatchQueue.main.asyncAfter(deadline: .now() + Motion.Selection.zoomInSeconds) { + guard mine == generation else { return } + withAnimation(Motion.Selection.zoomOut) { zoomed = false } + } + } + } +} + +private struct SelectionZoomedKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + /// True during the one-shot zoom of the newly selected row. + var isSelectionZoomed: Bool { + get { self[SelectionZoomedKey.self] } + set { self[SelectionZoomedKey.self] = newValue } + } +} + +extension View { + /// Marks this row as the selected one. `geometryID` is per list: two lists + /// on screen sharing one would make the pill fly between them. + func selectionPill( + isSelected: Bool, + themeStore: ThemeStore, + namespace: Namespace.ID, + geometryID: String = Motion.Selection.geometryID + ) -> some View { + modifier( + SelectionPillModifier( + isSelected: isSelected, + themeStore: themeStore, + namespace: namespace, + geometryID: geometryID)) + } +} diff --git a/apps/macos/LauncherApp/look-app/Views/Launcher/SmoothCaretTextField.swift b/apps/macos/LauncherApp/look-app/Views/Launcher/SmoothCaretTextField.swift index eb93adb5..9a5ce5df 100644 --- a/apps/macos/LauncherApp/look-app/Views/Launcher/SmoothCaretTextField.swift +++ b/apps/macos/LauncherApp/look-app/Views/Launcher/SmoothCaretTextField.swift @@ -6,6 +6,11 @@ import SwiftUI /// the launcher's existing focus recovery (`findEditableTextField`, which looks /// for an editable `NSTextField`) keeps working unchanged. struct SmoothCaretTextField: NSViewRepresentable { + /// How tall the input may grow before it scrolls instead. Six lines is a + /// composer, not an editor: past that the launcher would swallow the panel + /// it is meant to sit above. + private static let maxVisibleLines = 6 + @Binding var text: String var placeholder: String var isFocused: FocusState.Binding @@ -13,11 +18,16 @@ struct SmoothCaretTextField: NSViewRepresentable { /// Overrides the base theme font size when set (the Todo search bar runs a /// touch larger). Colors and family always follow the theme. var fontSize: CGFloat? = nil + /// Lets Shift+Return insert a line break and the field wrap and grow. Only + /// AI mode asks for it: the search bar is a single line by design, and a + /// query with a newline in it means nothing to the matcher. + var allowsMultiline: Bool = false var onSubmit: () -> Void private var font: NSFont { themeStore.uiNSFont(size: fontSize) } private var textColor: NSColor { NSColor(themeStore.fontColor()) } private var caretColor: NSColor { NSColor(themeStore.accentColor()) } + private var lineHeight: CGFloat { font.ascender - font.descender + font.leading } func makeNSView(context: Context) -> CaretTextField { let field = CaretTextField() @@ -25,28 +35,72 @@ struct SmoothCaretTextField: NSViewRepresentable { field.isBordered = false field.drawsBackground = false field.focusRingType = .none - field.usesSingleLineMode = true - field.cell?.isScrollable = true - field.cell?.wraps = false - field.lineBreakMode = .byClipping field.font = font field.textColor = textColor field.caretColor = caretColor field.setContentHuggingPriority(.defaultLow, for: .horizontal) field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + applyLineMode(to: field) + context.coordinator.appliedMultiline = allowsMultiline applyPlaceholder(to: field) return field } + /// Single line clips and scrolls sideways; multiline wraps at the field's + /// width and grows until `sizeThatFits` caps it. + private func applyLineMode(to field: CaretTextField) { + field.usesSingleLineMode = !allowsMultiline + field.lineBreakMode = allowsMultiline ? .byWordWrapping : .byClipping + // `wraps` and `isScrollable` are mutually exclusive in NSCell: setting + // either one clears the other. Assign ONLY the one this mode wants, or + // the second assignment silently undoes the first. + if allowsMultiline { + field.cell?.wraps = true + } else { + field.cell?.isScrollable = true + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, nsView field: CaretTextField, context: Context + ) -> CGSize? { + // nil means "size me the way you always did" - the single-line path is + // untouched. + guard allowsMultiline, let cell = field.cell else { return nil } + guard let width = proposal.width, width.isFinite, width > 0 else { return nil } + + let unbounded = NSRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude) + let wrapped = cell.cellSize(forBounds: unbounded).height + let cap = lineHeight * CGFloat(Self.maxVisibleLines) + return CGSize(width: width, height: min(max(wrapped, lineHeight), cap)) + } + func updateNSView(_ field: CaretTextField, context: Context) { context.coordinator.parent = self + if context.coordinator.appliedMultiline != allowsMultiline { + context.coordinator.appliedMultiline = allowsMultiline + applyLineMode(to: field) + // The field editor is configured from the cell when editing BEGINS, + // so a live edit would keep the old line mode until it restarts. + // Restart it here, putting the caret back where the user left it. + if let window = field.window, let editor = field.currentEditor() { + let selection = editor.selectedRange + window.makeFirstResponder(nil) + window.makeFirstResponder(field) + field.currentEditor()?.selectedRange = selection + field.refreshCaret(animated: false) + } + } + if field.stringValue != text { field.stringValue = text // A programmatic set (recall, clear) otherwise drops the caret to the // start; move it to the end so the recalled text is editable at once. field.currentEditor()?.selectedRange = NSRange(location: (text as NSString).length, length: 0) - field.refreshCaret(animated: true) + // Settling: a recalled prompt can be several lines, and the field + // has not grown to fit them at this point. + field.refreshCaretSettling() } field.font = font field.textColor = textColor @@ -80,6 +134,9 @@ struct SmoothCaretTextField: NSViewRepresentable { final class Coordinator: NSObject, NSTextFieldDelegate { var parent: SmoothCaretTextField + /// The line mode currently applied to the field, so a mode flip is + /// detected once rather than re-applied on every SwiftUI update. + var appliedMultiline = false init(_ parent: SmoothCaretTextField) { self.parent = parent } @@ -97,14 +154,37 @@ struct SmoothCaretTextField: NSViewRepresentable { } func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { - // Plain Return submits (Cmd+Return is consumed upstream by the - // keyboard monitor, so it never reaches here). Consume it so the - // field editor doesn't beep trying to insert a newline. - if selector == #selector(NSResponder.insertNewline(_:)) { - parent.onSubmit() + let isReturn = selector == #selector(NSResponder.insertNewline(_:)) + let isSoftReturn = selector == #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)) + guard isReturn || isSoftReturn else { return false } + + // A field editor routes Shift+Return to insertNewline: too, so the + // SELECTOR cannot tell a send from a line break - only the event + // can. Reading the selector alone sent the message instead. + let shiftHeld = NSApp.currentEvent.map { + $0.type == .keyDown && $0.modifierFlags.contains(.shift) + } ?? false + + if parent.allowsMultiline, isSoftReturn || shiftHeld { + textView.insertText("\n", replacementRange: textView.selectedRange()) + // insertText posts the change notification, but the binding is + // what the submit path reads: set it here so a send that lands + // in the same runloop turn cannot miss the newline. + parent.text = textView.string + // Past the height cap the box stops growing, so the new line + // has to be brought into view rather than left below the edge. + textView.scrollRangeToVisible(textView.selectedRange()) + // Settling, not immediate: the line this break just created is + // not laid out yet (see refreshCaretSettling). + (control as? CaretTextField)?.refreshCaretSettling() return true } - return false + + // Plain Return submits (Cmd+Return is consumed upstream by the + // keyboard monitor, so it never reaches here). Both forms are + // consumed either way, so the field editor never beeps. + if isReturn { parent.onSubmit() } + return true } } } @@ -117,6 +197,9 @@ final class CaretTextField: NSTextField { } private static let blinkKey = "blink" + /// One layout pass away, matching the launcher's other "let AppKit settle" + /// staged delays. Short enough that the correction is not seen as a move. + private static let caretSettleSeconds = 0.04 private let caretLayer = CALayer() private var selectionObserver: NSObjectProtocol? @@ -165,7 +248,7 @@ final class CaretTextField: NSTextField { override func textDidChange(_ notification: Notification) { super.textDidChange(notification) registerTyping() - refreshCaret(animated: true) + refreshCaretSettling() } override func textDidEndEditing(_ notification: Notification) { @@ -198,6 +281,18 @@ final class CaretTextField: NSTextField { // MARK: - Caret geometry + /// Measure now, then once more after layout has settled. A change that adds + /// a LINE (Shift+Return, or a word wrapping onto the next one) arrives here + /// before the field editor has grown and laid that line out, so the + /// immediate pass measures the old last line and parks the bar one line up + /// until the next keystroke corrects it. The second pass is what lands it. + func refreshCaretSettling() { + refreshCaret(animated: true) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.caretSettleSeconds) { [weak self] in + self?.refreshCaret(animated: true) + } + } + /// Recomputes the bar's frame from the field editor's layout and moves it, /// gliding when `animated`. func refreshCaret(animated: Bool) { @@ -223,25 +318,100 @@ final class CaretTextField: NSTextField { let caretLocation = editor.selectedRange().location layoutManager.ensureLayout(for: container) - // Width of the text preceding the caret gives its x; an empty range at 0 - // yields a zero rect, so the caret sits at the leading edge. - let precedingWidth = caretLocation > 0 + let fontLineHeight = font.map { $0.ascender - $0.descender + $0.leading } + ?? bounds.height + let barHeight = fontLineHeight * Motion.Caret.heightScale + let origin = editor.textContainerOrigin + + // Ask the text view where the insertion point is. This is the same + // answer input methods get, so it is right for the cases the layout + // manager makes awkward - notably a caret parked after a trailing + // newline, which lives in a fragment that holds no glyphs. + if let insertionLine = insertionPointLine(in: editor, at: caretLocation) { + return CGRect( + x: insertionLine.minX, y: insertionLine.midY - barHeight / 2, + width: Motion.Caret.width, height: barHeight) + } + + guard let line = lineGeometry( + at: caretLocation, layoutManager: layoutManager, container: container, editor: editor) + else { + // Nothing laid out yet (empty field): leading edge, centred. + let xInField = editor.convert(CGPoint(x: origin.x, y: 0), to: self).x + return CGRect( + x: xInField, y: (bounds.height - barHeight) / 2, + width: Motion.Caret.width, height: barHeight) + } + + // Convert as a RECT, not a point: the field editor is flipped and the + // field is not, so only a rect conversion gets both axes right when the + // caret is on the second or third line. + let inField = editor.convert( + CGRect( + x: origin.x + line.x, y: origin.y + line.rect.minY, + width: Motion.Caret.width, height: line.rect.height), + to: self) + + return CGRect( + x: inField.minX, y: inField.midY - barHeight / 2, + width: Motion.Caret.width, height: barHeight) + } + + /// The insertion point's line, in this field's coordinates, via the text + /// input protocol. Returns nil when the view is off screen or the rect + /// comes back degenerate, so the layout-manager path can still answer. + private func insertionPointLine(in editor: NSTextView, at location: Int) -> CGRect? { + guard let window else { return nil } + let onScreen = editor.firstRect( + forCharacterRange: NSRange(location: location, length: 0), actualRange: nil) + guard onScreen.height > 0 else { return nil } + return convert(window.convertFromScreen(onScreen), from: nil) + } + + /// Where the caret sits: the fragment rect of its line, plus the x offset + /// within that line. Returns nil when there is nothing laid out to measure. + private func lineGeometry( + at location: Int, layoutManager: NSLayoutManager, container: NSTextContainer, + editor: NSTextView + ) -> (rect: CGRect, x: CGFloat)? { + let length = (editor.string as NSString).length + let caretLocation = max(0, min(location, length)) + + let glyphCount = layoutManager.numberOfGlyphs + + // A caret parked after a trailing newline (or in an empty field) lives + // in the extra fragment, which holds no glyphs of its own. Falling + // through to the last glyph would put the bar on the newline character, + // which sits at the END of the PREVIOUS line - the bug this guards. + if caretLocation == length, length == 0 || editor.string.hasSuffix("\n") { + let extra = layoutManager.extraLineFragmentRect + if extra.height > 0 { return (extra, extra.minX) } + guard glyphCount > 0 else { return nil } + // No extra fragment laid out: step one line down from the last one. + let last = layoutManager.lineFragmentRect( + forGlyphAt: glyphCount - 1, effectiveRange: nil) + return (last.offsetBy(dx: 0, dy: last.height), last.minX) + } + + guard glyphCount > 0 else { return nil } + + let caretGlyph = min(layoutManager.glyphIndexForCharacter(at: caretLocation), glyphCount) + var lineGlyphRange = NSRange(location: 0, length: 0) + let lineRect = layoutManager.lineFragmentRect( + forGlyphAt: min(caretGlyph, glyphCount - 1), effectiveRange: &lineGlyphRange) + + // Width of this LINE's text up to the caret. Measuring from glyph 0 + // instead would push the caret off the right edge on every line but the + // first. + let precedingLength = caretGlyph - lineGlyphRange.location + let x = precedingLength > 0 ? layoutManager.boundingRect( - forGlyphRange: NSRange(location: 0, length: caretLocation), + forGlyphRange: NSRange(location: lineGlyphRange.location, length: precedingLength), in: container ).maxX - : 0 - - let origin = editor.textContainerOrigin - let xInEditor = origin.x + precedingWidth - let xInField = editor.convert(CGPoint(x: xInEditor, y: 0), to: self).x + : lineRect.minX - let lineHeight = font.map { $0.ascender - $0.descender + $0.leading } - ?? bounds.height - let barHeight = lineHeight * Motion.Caret.heightScale - let y = (bounds.height - barHeight) / 2 - - return CGRect(x: xInField, y: y, width: Motion.Caret.width, height: barHeight) + return (lineRect, x) } // MARK: - Blink / solid-while-typing @@ -254,7 +424,11 @@ final class CaretTextField: NSTextField { withTimeInterval: Motion.Caret.blinkResumeSeconds, repeats: false ) { [weak self] _ in - self?.startBlink() + // Timer fires on RunLoop.main; assumeIsolated avoids a needless + // Task hop while satisfying Swift 6's Sendable-closure check. + MainActor.assumeIsolated { + self?.startBlink() + } } } @@ -278,7 +452,11 @@ final class CaretTextField: NSTextField { object: editor, queue: .main ) { [weak self] _ in - self?.refreshCaret(animated: true) + // Delivered on `queue: .main`, so the caret can move without a hop + // that would land it a frame behind the selection. + MainActor.assumeIsolated { + self?.refreshCaret(animated: true) + } } } diff --git a/apps/macos/LauncherApp/look-app/Views/Settings/PermissionsRow.swift b/apps/macos/LauncherApp/look-app/Views/Settings/PermissionsRow.swift index ab892cf3..f3e23e4f 100644 --- a/apps/macos/LauncherApp/look-app/Views/Settings/PermissionsRow.swift +++ b/apps/macos/LauncherApp/look-app/Views/Settings/PermissionsRow.swift @@ -1,61 +1,205 @@ import AppKit import SwiftUI -/// One Settings row holding a chip per capability that needs OS access. Scales by -/// adding chips, not rows: a future connector (Contacts, Photos, ...) is one more -/// `PermissionChip` here, mirroring the action-tool registry. +/// One capability that needs macOS access, what Look does with it, and where it +/// lives in System Settings once it has been answered. +/// +/// macOS has no "grant everything" API: each family prompts on its own, from +/// its own call. `Grant all` therefore walks these in order rather than opening +/// one dialog. Automation is absent on purpose - it cannot be requested without +/// actually sending an Apple event, so it stays a first-use prompt. +nonisolated struct PermissionItem: Identifiable { + enum Capability: String { + case calendar + case reminders + case contacts + } + + let id: Capability + let title: String + /// What Look does with it. Lives in the tooltip rather than the row: the + /// settings panel is a column of one-line controls, and a paragraph per + /// permission pushed everything below it off the screen. + let purpose: String + /// The System Settings pane that owns it, for when only the user can change + /// the answer. + let settingsPane: String + + static let all: [PermissionItem] = [ + PermissionItem( + id: .calendar, + title: "Calendar", + purpose: "Add, move, and join meetings", + settingsPane: "Privacy_Calendars"), + PermissionItem( + id: .reminders, + title: "Reminders", + purpose: "Add, complete, and snooze reminders", + settingsPane: "Privacy_Reminders"), + PermissionItem( + id: .contacts, + title: "Contacts", + purpose: "Find who to message or FaceTime by name", + settingsPane: "Privacy_Contacts"), + ] +} + +/// One Settings row holding a chip per capability that needs OS access. Scales +/// by adding chips, not rows: a future connector (Contacts, Photos, ...) is one +/// more entry in `PermissionItem.all`. struct PermissionsRow: View { let themeStore: ThemeStore - @State private var calendarStatus: CalendarAccess = .notDetermined + + @State private var states: [PermissionItem.Capability: CalendarAccess] = [:] + @State private var isGranting = false + /// Shown when a settings deep link fails, so the row never looks inert. + @State private var problem: String? + + private func state(_ item: PermissionItem) -> CalendarAccess { + states[item.id, default: .notDetermined] + } + + /// Capabilities that have never been answered. Only these can still be + /// prompted: macOS ignores a second request for one already decided. + private var unanswered: [PermissionItem] { + PermissionItem.all.filter { state($0) == .notDetermined } + } var body: some View { - HStack(spacing: 10) { + HStack(spacing: 8) { Text("Permissions") .frame(width: AppConstants.ThemeUI.labelWidth, alignment: .leading) .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 1), weight: .regular)) .foregroundStyle(themeStore.secondaryTextColor()) - PermissionChip( - label: "Calendar", - granted: calendarStatus.canWrite, - themeStore: themeStore - ) { - if calendarStatus.canWrite { - // Apps cannot revoke their own privacy grant; only the user - // can, in System Settings. Open the pane for them. - openSystemPrivacyCalendars() - } else { - Task { - await EventKitService.shared.requestAccess() - refresh() - } + ForEach(PermissionItem.all) { item in + PermissionChip( + label: item.title, + granted: state(item) == .authorized, + themeStore: themeStore, + help: helpText(item) + ) { + act(on: item) } } - // Future capabilities add a chip here, not a new row. + // Offered only while something can still be asked, so it is never a + // button that silently does nothing. + if !unanswered.isEmpty { + Button(isGranting ? "Asking…" : "Grant all") { grantAll() } + .buttonStyle(.plain) + .disabled(isGranting) + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 2), weight: .semibold)) + .foregroundStyle(themeStore.accentColor()) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(themeStore.controlFillColor(), in: Capsule()) + .help("Ask for each remaining permission in turn") + } + + if let problem { + Text(problem) + .font(themeStore.uiFont(size: CGFloat(themeStore.settings.fontSize - 3), weight: .regular)) + .foregroundStyle(themeStore.dangerColor()) + .lineLimit(1) + } Spacer(minLength: 0) } .onAppear(perform: refresh) + // A grant made in System Settings while Look is open should show up on + // the way back, without a relaunch. + .onReceive( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + ) { _ in refresh() } + } + + /// The chip carries a dot and a name, so the tooltip has to say what this + /// access is for and what clicking will do - which differs by state. + private func helpText(_ item: PermissionItem) -> String { + switch state(item) { + case .authorized: + return "\(item.purpose). Connected - open System Settings to revoke." + case .writeOnly: + return "\(item.purpose). Partial access: open System Settings to allow reading too." + case .notDetermined: + return "\(item.purpose). Click to connect." + case .denied, .restricted: + return "\(item.purpose). Denied - only System Settings can change it." + } + } + + private func act(on item: PermissionItem) { + switch state(item) { + case .notDetermined: + Task { + await request(item.id) + refresh() + } + // Answered either way, so only the user can change it, and only there. + case .authorized, .writeOnly, .denied, .restricted: + openSettings(pane: item.settingsPane) + } + } + + /// Walks the unanswered capabilities in turn. Sequential on purpose: the + /// prompts are modal one at a time, and firing them together would stack + /// dialogs the user cannot read. + private func grantAll() { + isGranting = true + Task { + for item in unanswered { + await request(item.id) + refresh() + } + isGranting = false + } + } + + private func request(_ capability: PermissionItem.Capability) async { + switch capability { + case .calendar: await EventKitService.shared.requestCalendarAccess() + case .reminders: await EventKitService.shared.requestReminderAccess() + case .contacts: await ContactsService.shared.requestAccess() + } } private func refresh() { - calendarStatus = EventKitService.shared.calendarAccess + states = [ + .calendar: EventKitService.shared.calendarAccess, + .reminders: EventKitService.shared.reminderAccess, + .contacts: ContactsService.shared.access, + ] } - private func openSystemPrivacyCalendars() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") { - NSWorkspace.shared.open(url) + /// Opens a Privacy pane. The modern identifier first - verified on macOS + /// 26, where `SecurityPrivacyExtension.appex` declares + /// `com.apple.settings.PrivacySecurity.extension` - with the legacy id as a + /// fallback for older releases that still answer to it. A silent no-op + /// would leave a button that looks broken, so a total failure says so. + private func openSettings(pane: String) { + let candidates = [ + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?\(pane)", + "x-apple.systempreferences:com.apple.preference.security?\(pane)", + ] + for candidate in candidates { + if let url = URL(string: candidate), NSWorkspace.shared.open(url) { + problem = nil + return + } } + problem = "Could not open System Settings. Privacy & Security > \(pane)." } } -/// A tappable capsule showing a capability's name and grant state. Green dot when -/// connected; shows "Connect" and requests access when not. +/// A tappable capsule showing a capability's name and grant state. Green dot +/// when connected; clicking connects it, or opens System Settings once macOS +/// has an answer on file. struct PermissionChip: View { let label: String let granted: Bool let themeStore: ThemeStore + let help: String let action: () -> Void var body: some View { @@ -73,8 +217,6 @@ struct PermissionChip: View { .background(themeStore.controlFillColor(), in: Capsule()) } .buttonStyle(.plain) - .help(granted - ? "\(label) connected. Open System Settings to revoke access." - : "Connect \(label)") + .help(help) } } diff --git a/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Advanced.swift b/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Advanced.swift index 5f4c9615..414dae3b 100644 --- a/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Advanced.swift +++ b/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Advanced.swift @@ -24,7 +24,9 @@ extension ThemeSettingsView { } if settings.backgroundImagePath != nil { Button("Clear") { - themeStore.setBackgroundImage(url: nil) + withAnimation(Motion.Fade.animation) { + themeStore.setBackgroundImage(url: nil) + } } } } @@ -172,8 +174,10 @@ extension ThemeSettingsView { Text(path) .lineLimit(1) Button { - themeStore.removeExtraFileScanRoot(path) - extraScanDirectoryMessage = nil + withAnimation(Motion.Insert.animation) { + themeStore.removeExtraFileScanRoot(path) + extraScanDirectoryMessage = nil + } } label: { Image(systemName: "xmark") .font(.system(size: 10, weight: .semibold)) @@ -185,6 +189,7 @@ extension ThemeSettingsView { .padding(.horizontal, 9) .padding(.vertical, 5) .background(themeStore.liftColor(opacity: 0.12), in: Capsule()) + .transition(Motion.Insert.transition) } } } @@ -224,7 +229,9 @@ extension ThemeSettingsView { Text(path) .lineLimit(1) Button { - themeStore.removeExcludedFolderPath(path) + withAnimation(Motion.Insert.animation) { + themeStore.removeExcludedFolderPath(path) + } } label: { Image(systemName: "xmark") .font(.system(size: 10, weight: .semibold)) @@ -236,6 +243,7 @@ extension ThemeSettingsView { .padding(.horizontal, 9) .padding(.vertical, 5) .background(themeStore.liftColor(opacity: 0.12), in: Capsule()) + .transition(Motion.Insert.transition) } } } @@ -375,7 +383,9 @@ extension ThemeSettingsView { panel.canChooseFiles = true panel.allowedContentTypes = [.image] if panel.runModal() == .OK { - themeStore.setBackgroundImage(url: panel.url) + withAnimation(Motion.Fade.animation) { + themeStore.setBackgroundImage(url: panel.url) + } } } @@ -385,7 +395,9 @@ extension ThemeSettingsView { panel.canChooseDirectories = true panel.canChooseFiles = false if panel.runModal() == .OK, let url = panel.url { - themeStore.addExcludedFolderPath(url: url) + withAnimation(Motion.Insert.animation) { + themeStore.addExcludedFolderPath(url: url) + } } } @@ -395,10 +407,12 @@ extension ThemeSettingsView { panel.canChooseDirectories = true panel.canChooseFiles = false if panel.runModal() == .OK, let url = panel.url { - if let error = themeStore.addExtraFileScanRoot(url: url) { - extraScanDirectoryMessage = error.message - } else { - extraScanDirectoryMessage = nil + withAnimation(Motion.Insert.animation) { + if let error = themeStore.addExtraFileScanRoot(url: url) { + extraScanDirectoryMessage = error.message + } else { + extraScanDirectoryMessage = nil + } } } } diff --git a/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Shortcuts.swift b/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Shortcuts.swift index 2348590a..17237f2c 100644 --- a/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Shortcuts.swift +++ b/apps/macos/LauncherApp/look-app/Views/Settings/ThemeSettingsView+Shortcuts.swift @@ -94,7 +94,7 @@ enum ShortcutDocs { items: [ ShortcutItem(keys: "Cmd+-", action: "Zoom out UI scale"), ShortcutItem(keys: "Cmd+=", action: "Zoom in UI scale"), - ShortcutItem(keys: "Cmd+0", action: "Reset UI scale"), + ShortcutItem(keys: "Cmd+0", action: "Reset UI scale (opens the tenth session while the AI list is up)"), ] ), ShortcutSectionData( diff --git a/bridge/ffi/Cargo.lock b/bridge/ffi/Cargo.lock index 80c54b46..c9bb6ac5 100644 --- a/bridge/ffi/Cargo.lock +++ b/bridge/ffi/Cargo.lock @@ -426,6 +426,9 @@ dependencies = [ [[package]] name = "look-matching" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "look-netspeed" diff --git a/bridge/ffi/src/calling_api.rs b/bridge/ffi/src/calling_api.rs new file mode 100644 index 00000000..6e697a41 --- /dev/null +++ b/bridge/ffi/src/calling_api.rs @@ -0,0 +1,84 @@ +//! C-ABI wrapper over `look_ai::calling`. Panic-safe at `lib.rs`. +//! +//! The shell owns the address book (Contacts on macOS) and the dialling; the +//! words and the URL are decided in core so every platform reads "call mom on +//! facetime" the same way. + +use crate::state::{cstr_to_string, json_cstring_or_null}; +use look_ai::calling::{self, Modality}; +use std::os::raw::c_char; + +/// The call request in `query` as JSON (`{"name":"mom","modality":null}`), or +/// the literal `null` when this is an ordinary search. +pub(crate) fn look_call_query_json_impl(query: *const c_char) -> *mut c_char { + let request = calling::call_query(&cstr_to_string(query)); + json_cstring_or_null(request.and_then(|request| serde_json::to_string(&request).ok())) +} + +/// The modality a bare "call" means, as an id. Read from core so the shell +/// never hard-codes a second opinion. +pub(crate) fn look_call_default_modality_impl() -> *mut c_char { + json_cstring_or_null(Some(Modality::DEFAULT.id().to_string())) +} + +/// The URL that dials `handle` with `modality` (an id from `Modality::id`), or +/// null when the modality is unknown. +pub(crate) fn look_call_url_impl(modality: *const c_char, handle: *const c_char) -> *mut c_char { + let handle = cstr_to_string(handle); + let Some(modality) = Modality::from_id(&cstr_to_string(modality)) else { + return std::ptr::null_mut(); + }; + let url = calling::call_url(modality, &handle); + json_cstring_or_null(Some(url)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + + fn call(f: impl Fn(*const c_char) -> *mut c_char, input: &str) -> String { + let input = CString::new(input).expect("valid"); + let ptr = f(input.as_ptr()); + if ptr.is_null() { + return String::new(); + } + let out = unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + crate::state::free_json_allocation(ptr); + out + } + + #[test] + fn the_grammar_crosses_the_boundary() { + assert_eq!( + call(look_call_query_json_impl, "call mom"), + r#"{"name":"mom","modality":null}"# + ); + assert_eq!( + call(look_call_query_json_impl, "facetime sarah lee"), + r#"{"name":"sarah lee","modality":"face_time_video"}"# + ); + assert_eq!(call(look_call_query_json_impl, "recall that"), "null"); + } + + #[test] + fn urls_are_built_from_the_modality_id() { + let modality = CString::new("message").expect("valid"); + let handle = CString::new("+1 (555) 123-4567").expect("valid"); + let ptr = look_call_url_impl(modality.as_ptr(), handle.as_ptr()); + let url = unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + crate::state::free_json_allocation(ptr); + assert_eq!(url, "sms:+15551234567"); + } + + #[test] + fn an_unknown_modality_is_null_not_a_guess() { + let modality = CString::new("carrier pigeon").expect("valid"); + let handle = CString::new("+15551234567").expect("valid"); + assert!(look_call_url_impl(modality.as_ptr(), handle.as_ptr()).is_null()); + } +} diff --git a/bridge/ffi/src/lib.rs b/bridge/ffi/src/lib.rs index 5a61dd8c..5d971544 100644 --- a/bridge/ffi/src/lib.rs +++ b/bridge/ffi/src/lib.rs @@ -3,9 +3,11 @@ mod ai_api; mod answers_api; mod calc_api; +mod calling_api; mod clipboard_api; mod lunar_api; mod matching_api; +mod meeting_api; mod netspeed_api; mod qactions_api; mod runtime_config; @@ -159,6 +161,69 @@ pub extern "C" fn look_lunar_date_json(year: i64, month: i64, day: i64, tz: f64) .unwrap_or(std::ptr::null_mut()) } +/// The call request in `query` (`{"name":"mom","modality":null}`), or the +/// literal `null` for an ordinary search. Tier-1 grammar, cheap enough to call +/// on every keystroke. Free the result with `look_free_cstring`. +#[unsafe(no_mangle)] +pub extern "C" fn look_call_query_json(query: *const c_char) -> *mut c_char { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + calling_api::look_call_query_json_impl(query) + })) + .unwrap_or(std::ptr::null_mut()) +} + +/// The modality a bare "call" means (a `Modality` id). Free with +/// `look_free_cstring`. +#[unsafe(no_mangle)] +pub extern "C" fn look_call_default_modality() -> *mut c_char { + std::panic::catch_unwind(std::panic::AssertUnwindSafe( + calling_api::look_call_default_modality_impl, + )) + .unwrap_or(std::ptr::null_mut()) +} + +/// The URL that dials `handle` with `modality` (a `Modality` id such as +/// `face_time_audio`). Null when the modality is unknown. Free the result with +/// `look_free_cstring`. +#[unsafe(no_mangle)] +pub extern "C" fn look_call_url(modality: *const c_char, handle: *const c_char) -> *mut c_char { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + calling_api::look_call_url_impl(modality, handle) + })) + .unwrap_or(std::ptr::null_mut()) +} + +/// The join request in `query` (`{}` for a bare "join", `{"name": "..."}` when +/// it names a meeting), or the literal `null` for an ordinary search. Tier-1 +/// grammar, cheap enough to call on every keystroke. Free the result with +/// `look_free_cstring`. +#[unsafe(no_mangle)] +pub extern "C" fn look_meeting_join_query_json(query: *const c_char) -> *mut c_char { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + meeting_api::look_meeting_join_query_json_impl(query) + })) + .unwrap_or(std::ptr::null_mut()) +} + +/// What a `join` found in the events the shell fetched. +/// +/// `events_json` is an array of `{title, startUnixS, endUnixS, url?, location?, +/// notes?, allDay?}`. `name` narrows to meetings whose title carries those +/// words; pass an empty string for "whatever is next". Returns +/// `{"meetings":[...],"withoutLink":[...]}`, the second list naming events that +/// matched but carry no join link. Free the result with `look_free_cstring`. +#[unsafe(no_mangle)] +pub extern "C" fn look_meeting_outcome_json( + events_json: *const c_char, + now_epoch: i64, + name: *const c_char, +) -> *mut c_char { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + meeting_api::look_meeting_outcome_json_impl(events_json, now_epoch, name) + })) + .unwrap_or(std::ptr::null_mut()) +} + /// A full speed test as JSON (`{"ok":true,"reading":{...}}` or /// `{"ok":false,"error":"..."}`). Blocks for 15 seconds and up, so call it off /// the UI thread. Free the result with `look_free_cstring`. diff --git a/bridge/ffi/src/meeting_api.rs b/bridge/ffi/src/meeting_api.rs new file mode 100644 index 00000000..6b19f816 --- /dev/null +++ b/bridge/ffi/src/meeting_api.rs @@ -0,0 +1,143 @@ +//! C-ABI wrapper over `look_ai::meeting`. Panic-safe at `lib.rs`. +//! +//! The shell owns the calendar (EventKit here, something else elsewhere) and +//! hands over the events it fetched; the choice of WHICH meeting, and the link +//! hiding in it, are decided in core so every platform answers the same. + +use crate::state::{cstr_to_string, json_cstring_or_null}; +use look_ai::meeting::{self, EventInput}; +use std::os::raw::c_char; + +const JSON_EMPTY_OUTCOME: &str = r#"{"meetings":[],"withoutLink":[]}"#; + +/// The join request in `query` as JSON (`{"name": "standup"}`, or `{}` for a +/// bare "join"), or the literal `null` when this is an ordinary search. +pub(crate) fn look_meeting_join_query_json_impl(query: *const c_char) -> *mut c_char { + let request = meeting::join_query(&cstr_to_string(query)); + json_cstring_or_null(request.and_then(|request| serde_json::to_string(&request).ok())) +} + +/// What a `join` found, as JSON: `{"meetings":[...],"withoutLink":["Testing"]}`. +/// The head of `meetings` is what a bare "join" takes, so a picker and a direct +/// join can never disagree about which meeting is next. `withoutLink` names the +/// events that answered to the name but carry no link, so the shell can say +/// which meeting is missing one instead of claiming it does not exist. +pub(crate) fn look_meeting_outcome_json_impl( + events_json: *const c_char, + now_epoch: i64, + name: *const c_char, +) -> *mut c_char { + let raw = cstr_to_string(events_json); + // A malformed payload is "no meetings", never a panic: this runs on the + // launcher's open path. + let events: Vec = serde_json::from_str(&raw).unwrap_or_default(); + // An empty name is "no name": the shell passes "" rather than juggling a + // null pointer across the boundary. + let name = cstr_to_string(name); + let name = if name.trim().is_empty() { + None + } else { + Some(name) + }; + let outcome = meeting::join_outcome(&events, now_epoch, name.as_deref()); + json_cstring_or_null(Some( + serde_json::to_string(&outcome).unwrap_or_else(|_| JSON_EMPTY_OUTCOME.to_string()), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + + fn call(events_json: &str, now: i64) -> String { + call_named(events_json, now, "") + } + + fn call_named(events_json: &str, now: i64, name: &str) -> String { + let input = CString::new(events_json).expect("valid"); + let name = CString::new(name).expect("valid"); + let ptr = look_meeting_outcome_json_impl(input.as_ptr(), now, name.as_ptr()); + let out = unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + crate::state::free_json_allocation(ptr); + out + } + + fn join_query(query: &str) -> String { + let input = CString::new(query).expect("valid"); + let ptr = look_meeting_join_query_json_impl(input.as_ptr()); + let out = unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + crate::state::free_json_allocation(ptr); + out + } + + #[test] + fn the_join_grammar_crosses_the_boundary() { + assert_eq!(join_query("join"), "{}"); + assert_eq!(join_query("join my next meeting"), "{}"); + assert_eq!(join_query("join testing"), r#"{"name":"testing"}"#); + assert_eq!(join_query("standup notes"), "null"); + } + + #[test] + fn a_name_selects_among_the_events() { + let events = r#"[ + {"title":"Sooner","startUnixS":1000,"endUnixS":2000, + "url":"https://meet.jit.si/sooner"}, + {"title":"Testing","startUnixS":5000,"endUnixS":6000, + "url":"https://meet.jit.si/testing"} + ]"#; + assert!(call_named(events, 900, "testing").contains("\"title\":\"Testing\"")); + // Empty name means "whatever is next", not "match nothing". + assert!(call_named(events, 900, "").contains("\"title\":\"Sooner\"")); + assert!(call_named(events, 900, "retro").contains(r#""meetings":[]"#)); + } + + #[test] + fn returns_the_candidates_as_json() { + let events = r#"[ + {"title":"Standup","startUnixS":1000,"endUnixS":2000, + "url":"https://meet.google.com/abc-defg-hij"} + ]"#; + let json = call(events, 900); + assert!(json.contains("\"title\":\"Standup\""), "got {json}"); + assert!(json.contains("\"provider\":\"meet\""), "got {json}"); + assert!( + json.contains("\"providerLabel\":\"Google Meet\""), + "got {json}" + ); + assert!(json.contains("\"startsInS\":100"), "got {json}"); + } + + #[test] + fn names_what_it_matched_but_could_not_join() { + let events = r#"[{"title":"Desk work","startUnixS":1000,"endUnixS":2000}]"#; + let json = call(events, 900); + assert!(json.contains(r#""meetings":[]"#), "got {json}"); + assert!( + json.contains(r#""withoutLink":["Desk work"]"#), + "got {json}" + ); + } + + #[test] + fn a_malformed_payload_is_not_a_meeting() { + assert!(call("not json", 900).contains(r#""meetings":[]"#)); + assert!(call("", 900).contains(r#""meetings":[]"#)); + } + + #[test] + fn optional_fields_may_be_absent() { + // The shell omits url/location/notes/allDay when empty; serde defaults + // must cover that or every event would fail to decode. + let events = r#"[ + {"title":"Sync","startUnixS":1000,"endUnixS":2000, + "notes":"Join https://meet.jit.si/sync"} + ]"#; + assert!(call(events, 900).contains("meet.jit.si/sync")); + } +} diff --git a/core/Cargo.lock b/core/Cargo.lock index 69ebb54b..0a73833b 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -356,6 +356,9 @@ dependencies = [ [[package]] name = "look-matching" version = "0.1.0" +dependencies = [ + "unicode-normalization", +] [[package]] name = "look-netspeed" diff --git a/core/ai/src/calling.rs b/core/ai/src/calling.rs new file mode 100644 index 00000000..5b84ddaf --- /dev/null +++ b/core/ai/src/calling.rs @@ -0,0 +1,319 @@ +//! Placing a call from a typed line: "call mom", "facetime sarah", +//! "message alex on iphone". +//! +//! Like the meeting join tier, this needs no API and no network - macOS reaches +//! FaceTime, the phone, and Messages through URL schemes. Only two things are +//! genuinely shared across shells and so live here: reading the intent out of +//! the words, and turning a handle into the URL that dials it. Finding the +//! contact belongs to the platform (Contacts on macOS), and dialling is one +//! `open`. + +/// How to reach someone. Not interchangeable: FaceTime works from the Mac +/// alone, while `tel:` routes through a nearby iPhone over Continuity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Modality { + FaceTimeAudio, + FaceTimeVideo, + /// Dials through the user's iPhone. + Phone, + Message, +} + +impl Modality { + /// What a bare "call mom" means. FaceTime audio because it is the one that + /// works with nothing but this Mac; `tel:` silently needs an iPhone in + /// range, which is a bad default for a launcher that promises to just work. + pub const DEFAULT: Modality = Modality::FaceTimeAudio; + + /// Name for the UI ("Call mom · FaceTime audio"). + pub fn label(self) -> &'static str { + match self { + Modality::FaceTimeAudio => "FaceTime audio", + Modality::FaceTimeVideo => "FaceTime video", + Modality::Phone => "Call via iPhone", + Modality::Message => "Message", + } + } + + /// Stable id across the FFI, matching the serde representation. Hand-written + /// on both sides so a shell can name a modality without pulling in serde. + pub fn id(self) -> &'static str { + match self { + Modality::FaceTimeAudio => "face_time_audio", + Modality::FaceTimeVideo => "face_time_video", + Modality::Phone => "phone", + Modality::Message => "message", + } + } + + pub fn from_id(id: &str) -> Option { + [ + Modality::FaceTimeAudio, + Modality::FaceTimeVideo, + Modality::Phone, + Modality::Message, + ] + .into_iter() + .find(|modality| modality.id() == id) + } + + /// The URL scheme that starts it. + fn scheme(self) -> &'static str { + match self { + Modality::FaceTimeAudio => "facetime-audio://", + Modality::FaceTimeVideo => "facetime://", + Modality::Phone => "tel:", + Modality::Message => "sms:", + } + } +} + +/// A parsed "call ..." line: who, and how if the words said so. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallRequest { + /// The words naming the person, for the shell to match against Contacts. + pub name: String, + /// None when the line did not say, so the shell applies `Modality::DEFAULT` + /// or asks. Kept as "unsaid" rather than defaulted here: a picker wants to + /// know the difference between "they chose audio" and "they said nothing". + pub modality: Option, +} + +/// Verbs that open a call request, and the modality each one implies. +const VERBS: &[(&str, Option)] = &[ + ("call", None), + ("facetime", Some(Modality::FaceTimeVideo)), + ("ring", Some(Modality::Phone)), + ("phone", Some(Modality::Phone)), + ("message", Some(Modality::Message)), + ("text", Some(Modality::Message)), + ("imessage", Some(Modality::Message)), +]; + +/// Words after the verb that name a SERVICE. These always win: "facetime +/// sarah with audio" asked for audio. +const SERVICE_WORDS: &[(&str, Modality)] = &[ + ("facetime", Modality::FaceTimeVideo), + ("video", Modality::FaceTimeVideo), + ("audio", Modality::FaceTimeAudio), + ("voice", Modality::FaceTimeAudio), + ("message", Modality::Message), + ("text", Modality::Message), + ("imessage", Modality::Message), + ("sms", Modality::Message), +]; + +/// Words naming a DEVICE. They only decide when the verb did not, so +/// "message alex on iphone" stays a message. +const DEVICE_WORDS: &[(&str, Modality)] = &[ + ("iphone", Modality::Phone), + ("phone", Modality::Phone), + ("mobile", Modality::Phone), + ("cell", Modality::Phone), +]; + +/// Words that carry neither a name nor a modality. +const FILLER: &[&str] = &[ + "my", "the", "a", "up", "on", "by", "via", "with", "to", "using", "please", "over", +]; + +/// The call request in the typed text, or None for an ordinary search. A name +/// is required, and one that matches no contact shows nothing, so "call stack" +/// stays a file search. +pub fn call_query(input: &str) -> Option { + let lower = input.trim().to_lowercase(); + let mut words = lower + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()); + + let verb = words.next()?; + let (_, implied) = VERBS.iter().find(|(candidate, _)| *candidate == verb)?; + let mut modality = *implied; + + let mut name_words: Vec<&str> = Vec::new(); + for word in words { + if let Some((_, service)) = SERVICE_WORDS.iter().find(|(w, _)| *w == word) { + modality = Some(*service); + continue; + } + if let Some((_, device)) = DEVICE_WORDS.iter().find(|(w, _)| *w == word) { + // Never overrides the verb. + if modality.is_none() { + modality = Some(*device); + } + continue; + } + if FILLER.contains(&word) { + continue; + } + name_words.push(word); + } + + if name_words.is_empty() { + return None; + } + Some(CallRequest { + name: name_words.join(" "), + modality, + }) +} + +/// The URL that places the call. Handles are what Contacts hands over: a phone +/// number as the user typed it into their address book, or an email/Apple ID. +pub fn call_url(modality: Modality, handle: &str) -> String { + format!("{}{}", modality.scheme(), sanitize_handle(handle)) +} + +/// Strips the punctuation people put in phone numbers, which the schemes do not +/// want, while leaving an email (or any handle with a letter in it) untouched. +fn sanitize_handle(handle: &str) -> String { + let trimmed = handle.trim(); + let looks_numeric = trimmed + .chars() + .all(|c| c.is_ascii_digit() || " ()-.+".contains(c)); + if !looks_numeric { + return trimmed.to_string(); + } + trimmed + .chars() + .filter(|c| c.is_ascii_digit() || *c == '+') + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(input: &str) -> CallRequest { + call_query(input).unwrap_or_else(|| panic!("expected a call request: {input}")) + } + + #[test] + fn a_bare_call_names_the_person_and_leaves_how_unsaid() { + let parsed = request("call mom"); + assert_eq!(parsed.name, "mom"); + assert_eq!(parsed.modality, None); + } + + #[test] + fn the_verb_can_carry_the_modality() { + assert_eq!( + request("facetime sarah").modality, + Some(Modality::FaceTimeVideo) + ); + assert_eq!(request("message alex").modality, Some(Modality::Message)); + assert_eq!(request("text alex").modality, Some(Modality::Message)); + assert_eq!(request("ring dad").modality, Some(Modality::Phone)); + } + + #[test] + fn trailing_words_refine_the_modality_and_never_the_name() { + for (input, expected) in [ + ("call sarah on facetime", Modality::FaceTimeVideo), + ("call sarah by video", Modality::FaceTimeVideo), + ("call mom on iphone", Modality::Phone), + ("call mom on her mobile", Modality::Phone), + ("facetime sarah with audio", Modality::FaceTimeAudio), + ("call alex via sms", Modality::Message), + // The verb wins over a device qualifier: this is a message to a + // phone, not a phone call. + ("message alex on iphone", Modality::Message), + ("text mom on her mobile", Modality::Message), + ] { + let parsed = request(input); + assert_eq!(parsed.modality, Some(expected), "for {input}"); + assert!( + !parsed.name.contains("facetime") && !parsed.name.contains("iphone"), + "modality words leaked into the name: {}", + parsed.name + ); + } + } + + #[test] + fn filler_is_dropped_and_the_rest_is_the_name() { + assert_eq!(request("call up my mom please").name, "mom"); + assert_eq!(request("call sarah lee").name, "sarah lee"); + // "her" is not filler: it is rare in a name but harmless there, and a + // list of pronouns would be a lexicon this tier does not need. + assert_eq!(request("call mom on her mobile").name, "mom her"); + } + + #[test] + fn a_line_naming_nobody_is_not_a_call() { + for input in ["call", "facetime", "call up", "call on iphone", ""] { + assert_eq!(call_query(input), None, "for {input}"); + } + } + + #[test] + fn a_line_that_does_not_open_with_a_call_verb_is_ordinary_search() { + for input in ["recall mom", "calls", "calling mom", "the call", "callback"] { + assert_eq!(call_query(input), None, "for {input}"); + } + } + + #[test] + fn a_name_that_matches_no_contact_is_the_shell_s_problem() { + // "call stack" parses; it produces nothing because no contact answers + // to "stack", exactly as "join two pdfs" produces no meeting. + assert_eq!(request("call stack").name, "stack"); + } + + #[test] + fn urls_use_the_scheme_each_modality_needs() { + assert_eq!( + call_url(Modality::FaceTimeVideo, "+15551234567"), + "facetime://+15551234567" + ); + assert_eq!( + call_url(Modality::FaceTimeAudio, "+15551234567"), + "facetime-audio://+15551234567" + ); + assert_eq!( + call_url(Modality::Phone, "+15551234567"), + "tel:+15551234567" + ); + assert_eq!( + call_url(Modality::Message, "+15551234567"), + "sms:+15551234567" + ); + } + + #[test] + fn phone_punctuation_is_stripped_but_an_email_is_not() { + assert_eq!( + call_url(Modality::Phone, "+1 (555) 123-4567"), + "tel:+15551234567" + ); + assert_eq!( + call_url(Modality::FaceTimeVideo, "sarah@example.com"), + "facetime://sarah@example.com" + ); + } + + #[test] + fn every_modality_survives_the_string_round_trip() { + for modality in [ + Modality::FaceTimeAudio, + Modality::FaceTimeVideo, + Modality::Phone, + Modality::Message, + ] { + assert_eq!(Modality::from_id(modality.id()), Some(modality)); + // The id must match what serde writes, or the two sides of the FFI + // would disagree about the same value. + let json = serde_json::to_string(&modality).expect("serializable"); + assert_eq!(json, format!("\"{}\"", modality.id())); + } + assert_eq!(Modality::from_id("carrier pigeon"), None); + } + + #[test] + fn the_default_is_the_one_that_needs_no_iphone() { + assert_eq!(Modality::DEFAULT, Modality::FaceTimeAudio); + assert_eq!(Modality::DEFAULT.label(), "FaceTime audio"); + } +} diff --git a/core/ai/src/chat.rs b/core/ai/src/chat.rs index 1e84ba2f..67af9e6f 100644 --- a/core/ai/src/chat.rs +++ b/core/ai/src/chat.rs @@ -123,6 +123,10 @@ pub fn start(host: &str, model: &str, messages_json: &str, options_json: &str) - start_request(&url, &body, timeout_secs) } +/// Bounds the connect phase only, so an unreachable host fails in seconds +/// rather than holding the UI for the whole answer timeout. +pub(crate) const CONNECT_TIMEOUT_SECS: u32 = 5; + /// Spawns a curl session POSTing `body` to `url`; the reader thread accumulates /// `message.content` deltas (streamed NDJSON and single-response lines both /// parse). Returns a pollable session id, or 0 when the spawn/write fails. @@ -133,6 +137,8 @@ pub(crate) fn start_request(url: &str, body: &str, max_time_secs: u32) -> u64 { command .arg("-sS") .arg("--no-buffer") + .arg("--connect-timeout") + .arg(CONNECT_TIMEOUT_SECS.to_string()) .arg("--max-time") .arg(max_time_secs.to_string()) .arg("-H") @@ -296,7 +302,10 @@ mod tests { let id = start("http://127.0.0.1:1", "m", "[]", ""); assert_ne!(id, 0); let mut last = String::new(); - for _ in 0..100 { + // Outlasts CONNECT_TIMEOUT_SECS: a platform that drops the SYN rather + // than refusing it takes the whole connect budget to fail. + let attempts = (CONNECT_TIMEOUT_SECS as usize + 5) * 20; + for _ in 0..attempts { let Some(snapshot) = poll(id) else { break }; last = snapshot; if last.contains("\"done\":true") { diff --git a/core/ai/src/lib.rs b/core/ai/src/lib.rs index b5404905..36600609 100644 --- a/core/ai/src/lib.rs +++ b/core/ai/src/lib.rs @@ -3,6 +3,7 @@ //! Ported module by module from the macOS Swift package; the Swift tests are //! the parity spec and are deleted as their Rust replacements land. +pub mod calling; pub mod chat; pub mod context; pub mod conversations; @@ -12,6 +13,7 @@ pub mod files; pub mod lexicon; pub mod markdown; pub mod matcher; +pub mod meeting; pub mod memory; pub mod ollama; pub mod plan; diff --git a/core/ai/src/meeting/grammar.rs b/core/ai/src/meeting/grammar.rs new file mode 100644 index 00000000..d27f2066 --- /dev/null +++ b/core/ai/src/meeting/grammar.rs @@ -0,0 +1,115 @@ +//! Reading "join" out of a typed line. Tier 1: a fixed grammar, no model, +//! cheap enough for every keystroke. + +/// The verb that opens a join request. Deliberately the only one: "open" and +/// "start" are already spoken for by apps and files. +const JOIN_VERB: &str = "join"; + +/// Words that carry no meeting name of their own, so "join my next meeting" +/// means "whatever is next". Everything else after the verb is read as the +/// name of a meeting. Provider names count as filler ("join zoom" is not +/// hunting for an event titled Zoom); ordinary words like "standup" do NOT, +/// because that is exactly how people name their meetings. +const JOIN_FILLER: &[&str] = &[ + "meeting", "meetings", "call", "my", "the", "a", "next", "now", "up", "in", "current", "zoom", + "teams", "meet", "webex", "please", +]; + +/// A parsed join request: the words after `join` that were not filler, if any. +/// `None` name means "whatever is next". +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinRequest { + /// Absent from the JSON when there is no name, so a bare "join" crosses the + /// boundary as `{}` rather than a null the shell has to special-case. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +/// The join request in the typed text, or None when this is an ordinary search. +/// +/// Tier 1: a fixed grammar, no model, cheap enough for every keystroke. Only +/// the leading verb is fixed; the rest is either filler ("my next meeting") or +/// the name of the meeting to join ("join standup"). Naming one is the shape +/// people reach for first, and it is safe here because a name that matches no +/// meeting produces no row at all, so "join two pdfs" still falls through to +/// file search. +pub fn join_query(input: &str) -> Option { + let lower = input.trim().to_lowercase(); + let mut words = lower + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()); + + if words.next() != Some(JOIN_VERB) { + return None; + } + let name: Vec<&str> = words.filter(|word| !JOIN_FILLER.contains(word)).collect(); + Some(JoinRequest { + name: if name.is_empty() { + None + } else { + Some(name.join(" ")) + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_phrasings_are_recognised() { + for phrasing in [ + "join", + "Join", + " join ", + "join meeting", + "join my meeting", + "join the meeting", + "join my next meeting", + "join next call", + "join now", + "join zoom", + "join teams meeting", + ] { + assert_eq!( + join_query(phrasing), + Some(JoinRequest { name: None }), + "expected a nameless join query: {phrasing}" + ); + } + } + #[test] + fn the_words_after_join_name_a_meeting() { + for (phrasing, expected) in [ + ("join testing", "testing"), + ("Join Testing", "testing"), + ("join the design review", "design review"), + ("join the standup", "standup"), + ("join my standup with sarah", "standup with sarah"), + // Punctuation splits like any other separator, and the pieces are + // matched against the title independently, so `1:1` still finds it. + ("join 1:1", "1 1"), + ] { + assert_eq!( + join_query(phrasing), + Some(JoinRequest { + name: Some(expected.to_string()) + }), + "for {phrasing}" + ); + } + } + #[test] + fn a_search_that_does_not_start_with_join_is_never_a_join_query() { + // A NAME is allowed after the verb now, so the guard is the verb itself + // plus the fact that a name matching no meeting yields no row at all. + for phrasing in ["joins", "joint account", "adjoin", "rejoin meeting", ""] { + assert_eq!( + join_query(phrasing), + None, + "expected a plain search: {phrasing}" + ); + } + } +} diff --git a/core/ai/src/meeting/link.rs b/core/ai/src/meeting/link.rs new file mode 100644 index 00000000..fba3997d --- /dev/null +++ b/core/ai/src/meeting/link.rs @@ -0,0 +1,372 @@ +//! Finding the join link inside the text of an invite. +//! +//! The hard part is not finding *a* URL. An invite body is full of them - help +//! pages, meeting options, dial-in pages, the doc someone attached - so each +//! provider is matched by its JOIN shape specifically, and everything else is +//! ignored rather than ranked. + +use std::sync::LazyLock; + +use regex::Regex; + +/// A conferencing service we can recognise a join link for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Provider { + Teams, + Zoom, + Meet, + Webex, + Jitsi, + GoToMeeting, + Whereby, +} + +impl Provider { + /// Name for the UI ("Join Zoom meeting"). + pub fn label(self) -> &'static str { + match self { + Provider::Teams => "Teams", + Provider::Zoom => "Zoom", + Provider::Meet => "Google Meet", + Provider::Webex => "Webex", + Provider::Jitsi => "Jitsi", + Provider::GoToMeeting => "GoToMeeting", + Provider::Whereby => "Whereby", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct JoinLink { + /// Always absolute and https, ready to hand to the OS opener. + pub url: String, + pub provider: Provider, +} + +/// The join link for an event, or None when it is not an online meeting. +/// +/// Fields are searched in the order an organiser's intent is clearest: `url` is +/// where the provider or the organiser put the canonical link, `location` is +/// where Google and hand-made invites put it, and `notes` is last because it is +/// the field most polluted with other links. +pub fn find_join_link( + url: Option<&str>, + location: Option<&str>, + notes: Option<&str>, +) -> Option { + [url, location, notes] + .into_iter() + .flatten() + .find_map(first_join_link) +} + +/// The earliest join link in one field. Earliest rather than "best": each +/// pattern already matches only join shapes, so position is the only sensible +/// tie-break between two providers named in the same text (a Teams invite that +/// pastes a Zoom backup link, say). +fn first_join_link(text: &str) -> Option { + patterns() + .iter() + .filter_map(|(provider, re)| re.find(text).map(|m| (m.start(), *provider, m.as_str()))) + .min_by_key(|(start, _, _)| *start) + .map(|(_, provider, raw)| JoinLink { + url: normalize(raw), + provider, + }) +} + +/// Trailing characters a URL never ends with, but the text around it often +/// does: sentence punctuation, the closing half of `<...>` or `(...)`, and the +/// quote from an HTML `href`. +const TRAILING_NOISE: &[char] = &['.', ',', ';', ':', ')', ']', '>', '"', '\'', '!', '?']; + +const HTTPS_PREFIX: &str = "https://"; +const HTTP_PREFIX: &str = "http://"; +/// Exchange writes invite bodies as HTML, so a query string arrives entity +/// encoded. Left as-is the link still opens, but on the wrong meeting. +const ENCODED_AMPERSAND: &str = "&"; + +fn normalize(raw: &str) -> String { + let trimmed = raw.trim_end_matches(TRAILING_NOISE); + let decoded = trimmed.replace(ENCODED_AMPERSAND, "&"); + let lower = decoded.to_lowercase(); + if lower.starts_with(HTTPS_PREFIX) { + return decoded; + } + // A join URL carries a token and an invite is attacker-written, so http + // is upgraded rather than opened. + if lower.starts_with(HTTP_PREFIX) { + return format!("{HTTPS_PREFIX}{}", &decoded[HTTP_PREFIX.len()..]); + } + // Google in particular drops a bare host into `location`. + format!("{HTTPS_PREFIX}{decoded}") +} + +/// What a URL may still contain after its host. NOT `\S+`: an invite body is +/// often HTML, and `href="…">Join` has no whitespace to stop at, so the +/// quote and the angle brackets have to end the match themselves. +const URL_TAIL: &str = r#"[^\s"'<>]+"#; +/// The same, but allowed to be empty - for the middle of a path. +const URL_TAIL_OPTIONAL: &str = r#"[^\s"'<>]*"#; + +/// One join shape per provider. `(?i)` throughout: hosts are case insensitive +/// and some clients upper-case the whole line. The scheme is optional so a bare +/// host in `location` is still found; `normalize` puts one back. +fn patterns() -> &'static [(Provider, Regex)] { + static PATTERNS: LazyLock> = LazyLock::new(|| { + let compile = |source: &str| Regex::new(source).expect("valid"); + vec![ + // Join shapes only: a bare host would match the "Meeting options" + // and "Learn more" links beside it in every invite. + ( + Provider::Teams, + compile(&format!( + r"(?i)(https?://)?\b(teams\.microsoft\.com/(l/meetup-join/|meet/)|teams\.live\.com/meet/){URL_TAIL}" + )), + ), + // `/j/` is a meeting, `/w/` a webinar, `/my/` a personal room. + // `/u/` (a user profile) and `/rec/` (a recording) are not. + ( + Provider::Zoom, + compile(&format!( + r"(?i)(https?://)?\b([a-z0-9-]+\.)*zoom\.us/(j|w|my)/{URL_TAIL}" + )), + ), + ( + Provider::Meet, + compile(&format!( + r"(?i)(https?://)?\bmeet\.google\.com/[a-z0-9]{{3,}}-[a-z0-9-]{{3,}}{URL_TAIL_OPTIONAL}" + )), + ), + ( + Provider::Webex, + compile(&format!( + r"(?i)(https?://)?\b([a-z0-9-]+\.)*webex\.com/({URL_TAIL_OPTIONAL}/j\.php\?{URL_TAIL}|(meet|join)/{URL_TAIL})" + )), + ), + ( + Provider::Jitsi, + compile(&format!(r"(?i)(https?://)?\bmeet\.jit\.si/{URL_TAIL}")), + ), + ( + Provider::GoToMeeting, + compile(&format!( + r"(?i)(https?://)?\b((global\.)?gotomeeting\.com/join/{URL_TAIL}|gotomeet\.me/{URL_TAIL})" + )), + ), + ( + Provider::Whereby, + compile(&format!(r"(?i)(https?://)?\bwhereby\.com/{URL_TAIL}")), + ), + ] + }); + &PATTERNS +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Trimmed from a real Outlook invite: the join link is surrounded by three + /// other microsoft.com URLs that must all lose. + const TEAMS_NOTES: &str = r#"________________________________________________________________________________ +Microsoft Teams Need help? +Join the meeting now +Meeting ID: 123 456 789 +Or call in (audio only) +1 555-0100,,123456789# +Meeting options +"#; + + const ZOOM_NOTES: &str = r#"Alex Kim is inviting you to a scheduled Zoom meeting. + +Join Zoom Meeting +https://us02web.zoom.us/j/89123456789?pwd=Q2hhbmdlTWU + +Meeting ID: 891 2345 6789 +One tap mobile ++16465588656,,89123456789# US (New York) +Find your local number: https://us02web.zoom.us/u/kbXyZ1 +"#; + + #[test] + fn teams_picks_the_join_link_not_help_or_options() { + let link = find_join_link(None, None, Some(TEAMS_NOTES)).expect("a join link"); + assert_eq!(link.provider, Provider::Teams); + assert!( + link.url.contains("/l/meetup-join/"), + "expected the meetup-join link, got {}", + link.url + ); + assert!(!link.url.contains("aka.ms")); + assert!(!link.url.contains("meetingOptions")); + } + #[test] + fn teams_join_link_keeps_its_percent_encoded_context() { + let link = find_join_link(None, None, Some(TEAMS_NOTES)).expect("a join link"); + assert!(link.url.contains("19%3ameeting_NGI3@thread.v2/0")); + assert!(link.url.contains("context=%7b%22Tid%22%3a%22abc%22%7d")); + // The `<...>` wrapper is the mail client's, not part of the URL. + assert!(!link.url.ends_with('>')); + } + #[test] + fn zoom_picks_the_meeting_not_the_local_numbers_page() { + let link = find_join_link(None, None, Some(ZOOM_NOTES)).expect("a join link"); + assert_eq!(link.provider, Provider::Zoom); + assert_eq!( + link.url, + "https://us02web.zoom.us/j/89123456789?pwd=Q2hhbmdlTWU" + ); + } + #[test] + fn meet_in_location_without_a_scheme_becomes_absolute() { + let link = + find_join_link(None, Some("meet.google.com/abc-defg-hij"), None).expect("a join link"); + assert_eq!(link.provider, Provider::Meet); + assert_eq!(link.url, "https://meet.google.com/abc-defg-hij"); + } + #[test] + fn url_field_wins_over_notes() { + let link = find_join_link( + Some("https://meet.google.com/abc-defg-hij"), + None, + Some(ZOOM_NOTES), + ) + .expect("a join link"); + assert_eq!(link.provider, Provider::Meet); + } + #[test] + fn location_wins_over_notes() { + let link = find_join_link( + None, + Some("Join at https://meet.google.com/abc-defg-hij"), + Some(ZOOM_NOTES), + ) + .expect("a join link"); + assert_eq!(link.provider, Provider::Meet); + } + #[test] + fn sentence_punctuation_is_not_part_of_the_url() { + let link = find_join_link( + None, + None, + Some("Dial in at https://meet.jit.si/look-standup."), + ) + .expect("a join link"); + assert_eq!(link.provider, Provider::Jitsi); + assert_eq!(link.url, "https://meet.jit.si/look-standup"); + } + #[test] + fn html_encoded_ampersands_are_decoded() { + let notes = r#"Join"#; + let link = find_join_link(None, None, Some(notes)).expect("a join link"); + assert_eq!( + link.url, + "https://us02web.zoom.us/j/8912?pwd=abc&from=addon" + ); + } + #[test] + fn an_attached_document_is_not_a_meeting() { + let notes = "Agenda: https://docs.google.com/document/d/1a2b3c/edit\nRoom 4B"; + assert_eq!(find_join_link(None, Some("Room 4B"), Some(notes)), None); + } + #[test] + fn a_zoom_recording_is_not_a_join_link() { + let notes = "Last week's recording: https://us02web.zoom.us/rec/share/abc123"; + assert_eq!(find_join_link(None, None, Some(notes)), None); + } + #[test] + fn webex_matches_both_join_shapes() { + let hosted = find_join_link( + Some("https://acme.webex.com/acme/j.php?MTID=m123abc"), + None, + None, + ) + .expect("a join link"); + assert_eq!(hosted.provider, Provider::Webex); + + let personal = + find_join_link(Some("https://acme.webex.com/meet/alex"), None, None).expect("a link"); + assert_eq!(personal.provider, Provider::Webex); + } + #[test] + fn uppercased_invites_still_match() { + let link = find_join_link(None, Some("HTTPS://US02WEB.ZOOM.US/J/8912"), None) + .expect("a join link"); + assert_eq!(link.provider, Provider::Zoom); + // The original casing is preserved: the path may be case sensitive. + assert_eq!(link.url, "HTTPS://US02WEB.ZOOM.US/J/8912"); + } + #[test] + fn a_lookalike_host_does_not_match() { + for text in [ + "https://notmeet.google.com/abc-defg-hij", + "https://fakezoom.us.evil.com/j/1", + // A label that merely ENDS with the provider name. Anchoring each + // preceding label with a dot is what rejects these; matching any + // run of host characters accepted them. + "https://evilzoom.us/j/123", + "https://notwebex.com/meet/alex", + "https://myzoom.us/j/9", + ] { + assert_eq!(find_join_link(None, None, Some(text)), None, "for {text}"); + } + } + #[test] + fn a_real_subdomain_still_matches() { + for text in [ + "https://us02web.zoom.us/j/89123456789", + "https://zoom.us/j/89123456789", + "https://acme.webex.com/meet/alex", + ] { + assert!( + find_join_link(None, None, Some(text)).is_some(), + "for {text}" + ); + } + } + #[test] + fn a_plaintext_link_is_upgraded_not_opened_as_is() { + // A join URL carries a token, and an invite is attacker-influenced. + let link = + find_join_link(Some("http://us02web.zoom.us/j/8912"), None, None).expect("a join link"); + assert_eq!(link.url, "https://us02web.zoom.us/j/8912"); + } + #[test] + fn earliest_link_wins_when_an_invite_names_two_services() { + let notes = "Primary: https://meet.jit.si/look\nBackup: https://us02web.zoom.us/j/8912"; + let link = find_join_link(None, None, Some(notes)).expect("a join link"); + assert_eq!(link.provider, Provider::Jitsi); + } + #[test] + fn empty_and_absent_fields_are_not_meetings() { + assert_eq!(find_join_link(None, None, None), None); + assert_eq!(find_join_link(Some(""), Some(""), Some("")), None); + assert_eq!(find_join_link(None, Some("Meeting Room 3"), None), None); + } + #[test] + fn remaining_providers_are_recognised() { + for (text, expected) in [ + ("https://whereby.com/look-team", Provider::Whereby), + ("https://gotomeet.me/alexkim", Provider::GoToMeeting), + ( + "https://global.gotomeeting.com/join/123456789", + Provider::GoToMeeting, + ), + ("https://teams.live.com/meet/9312345", Provider::Teams), + ( + "https://teams.microsoft.com/meet/1234567890?p=xy", + Provider::Teams, + ), + ] { + let link = find_join_link(Some(text), None, None) + .unwrap_or_else(|| panic!("no link found in {text}")); + assert_eq!(link.provider, expected, "for {text}"); + } + } + #[test] + fn labels_are_display_ready() { + assert_eq!(Provider::Meet.label(), "Google Meet"); + assert_eq!(Provider::Teams.label(), "Teams"); + } +} diff --git a/core/ai/src/meeting/mod.rs b/core/ai/src/meeting/mod.rs new file mode 100644 index 00000000..6d63fb59 --- /dev/null +++ b/core/ai/src/meeting/mod.rs @@ -0,0 +1,21 @@ +//! Joining the meeting a calendar event points at. +//! +//! A Teams, Zoom, or Meet invite already carries everything needed to join, so +//! no API and no network are involved: the link is sitting in the event's own +//! fields. Pure text logic, kept here rather than in a shell, so every platform +//! that grows a calendar source inherits the same answer. +//! +//! Three jobs, one per file: read the request out of the words (`grammar`), +//! find the link inside an invite (`link`), and choose which meeting the +//! request means (`select`). + +mod grammar; +mod link; +mod select; + +pub use grammar::{JoinRequest, join_query}; +pub use link::{JoinLink, Provider, find_join_link}; +pub use select::{ + EventInput, IMMINENT_WINDOW_S, JoinOutcome, JoinableMeeting, join_outcome, joinable_meetings, + next_joinable, +}; diff --git a/core/ai/src/meeting/select.rs b/core/ai/src/meeting/select.rs new file mode 100644 index 00000000..380eb6bb --- /dev/null +++ b/core/ai/src/meeting/select.rs @@ -0,0 +1,361 @@ +//! Choosing which meeting to open, out of the events a shell fetched. + +use super::link::{JoinLink, Provider, find_join_link}; + +/// One calendar event as the shell hands it over. Only the fields a join link +/// can hide in, plus what it takes to pick between events. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventInput { + pub title: String, + pub start_unix_s: i64, + pub end_unix_s: i64, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub location: Option, + #[serde(default)] + pub notes: Option, + /// All-day entries are excluded outright. "Conference week" spanning the + /// whole day would otherwise outrank the standup starting in five minutes. + #[serde(default)] + pub all_day: bool, +} + +/// The meeting to join, with everything a surface needs to render it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinableMeeting { + pub title: String, + pub start_unix_s: i64, + pub end_unix_s: i64, + pub url: String, + pub provider: Provider, + /// Pre-rendered so a shell never re-implements the naming. + pub provider_label: String, + /// Seconds until it starts; negative once it has. + pub starts_in_s: i64, + pub in_progress: bool, +} + +/// How close to the start a meeting is worth surfacing unprompted. A proactive +/// tile earlier than this is noise, not help. +pub const IMMINENT_WINDOW_S: i64 = 15 * 60; + +impl JoinableMeeting { + /// Whether a proactive surface (a tile, a banner) should show this now. + pub fn is_imminent(&self) -> bool { + self.starts_in_s <= IMMINENT_WINDOW_S + } +} + +/// The meeting to join right now, out of the events the shell fetched. +/// +/// A meeting already under way wins over one starting sooner-but-later, which +/// is what "join my next meeting" means when you are five minutes late. Ended +/// events, all-day entries, and anything without a join link are not +/// candidates at all. With a `name`, only meetings whose title contains all of +/// its words qualify, so "join standup" skips past the thing starting sooner. +pub fn next_joinable( + events: &[EventInput], + now_unix_s: i64, + name: Option<&str>, +) -> Option { + joinable_meetings(events, now_unix_s, name) + .into_iter() + .next() +} + +/// What a `join` found: the meetings it can open, and the ones it matched by +/// name that carry no link, so the shell can name what is missing. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JoinOutcome { + pub meetings: Vec, + /// Titles that answered to the name but carry no join link, earliest + /// first, each named once. + pub without_link: Vec, +} + +/// Every meeting that could be joined, best first. A surface that asks the +/// user to pick needs the whole list; `next_joinable` is the head of it, so +/// the order the picker shows and the one a bare "join" takes cannot diverge. +pub fn joinable_meetings( + events: &[EventInput], + now_unix_s: i64, + name: Option<&str>, +) -> Vec { + join_outcome(events, now_unix_s, name).meetings +} + +/// The joinable meetings and the near-misses, in one pass over the events. +pub fn join_outcome(events: &[EventInput], now_unix_s: i64, name: Option<&str>) -> JoinOutcome { + let mut candidates: Vec<&EventInput> = events + .iter() + .filter(|event| !event.all_day && event.end_unix_s > now_unix_s) + .filter(|event| title_matches(&event.title, name)) + .collect(); + candidates.sort_by_key(|event| (event.start_unix_s.max(now_unix_s), event.start_unix_s)); + + let mut without_link: Vec = Vec::new(); + let mut found: Vec<(&EventInput, JoinLink)> = Vec::new(); + for event in candidates { + match find_join_link( + event.url.as_deref(), + event.location.as_deref(), + event.notes.as_deref(), + ) { + Some(link) => found.push((event, link)), + None => { + if !without_link.contains(&event.title) { + without_link.push(event.title.clone()); + } + } + } + } + + // The candidates were sorted before the link lookup, so both lists come out + // in the same order: anything in progress first, then by start time. + let meetings = found + .into_iter() + .map(|(event, link)| JoinableMeeting { + title: event.title.clone(), + start_unix_s: event.start_unix_s, + end_unix_s: event.end_unix_s, + url: link.url, + provider: link.provider, + provider_label: link.provider.label().to_string(), + starts_in_s: event.start_unix_s - now_unix_s, + in_progress: event.start_unix_s <= now_unix_s, + }) + .collect(); + JoinOutcome { + meetings, + without_link, + } +} + +/// Whether an event title answers to `name`. Containment, not fuzzy scoring: a +/// near-miss opens the wrong call. Folded like the file search, so `hop` finds +/// `Họp`. +fn title_matches(title: &str, name: Option<&str>) -> bool { + let Some(name) = name else { return true }; + let title = look_matching::normalize_for_search(title); + look_matching::normalize_for_search(name) + .split_whitespace() + .all(|word| title.contains(word)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ZOOM_NOTES: &str = r#"Alex Kim is inviting you to a scheduled Zoom meeting. + +Join Zoom Meeting +https://us02web.zoom.us/j/89123456789?pwd=Q2hhbmdlTWU + +Meeting ID: 891 2345 6789 +One tap mobile ++16465588656,,89123456789# US (New York) +Find your local number: https://us02web.zoom.us/u/kbXyZ1 +"#; + + const NOW: i64 = 1_760_000_000; + const MINUTE: i64 = 60; + + fn event(title: &str, starts_in_min: i64, minutes: i64, link: Option<&str>) -> EventInput { + EventInput { + title: title.to_string(), + start_unix_s: NOW + starts_in_min * MINUTE, + end_unix_s: NOW + (starts_in_min + minutes) * MINUTE, + url: link.map(str::to_string), + location: None, + notes: None, + all_day: false, + } + } + + #[test] + fn a_meeting_already_running_beats_one_starting_sooner() { + let events = [ + event( + "Standup", + 2, + 15, + Some("https://meet.google.com/abc-defg-hij"), + ), + event("Design review", -10, 45, Some("https://meet.jit.si/design")), + ]; + let next = next_joinable(&events, NOW, None).expect("a meeting"); + assert_eq!(next.title, "Design review"); + assert!(next.in_progress); + assert_eq!(next.starts_in_s, -10 * MINUTE); + } + #[test] + fn the_earliest_upcoming_meeting_wins() { + let events = [ + event("Later", 90, 30, Some("https://meet.jit.si/later")), + event("Sooner", 20, 30, Some("https://meet.jit.si/sooner")), + ]; + let next = next_joinable(&events, NOW, None).expect("a meeting"); + assert_eq!(next.title, "Sooner"); + assert!(!next.in_progress); + assert_eq!(next.starts_in_s, 20 * MINUTE); + } + #[test] + fn finished_events_are_not_candidates() { + let events = [event("Over", -60, 30, Some("https://meet.jit.si/over"))]; + assert_eq!(next_joinable(&events, NOW, None), None); + } + #[test] + fn events_without_a_link_are_not_candidates() { + let events = [ + event("Desk work", 5, 60, None), + event("Sync", 30, 30, Some("https://meet.jit.si/sync")), + ]; + let next = next_joinable(&events, NOW, None).expect("a meeting"); + assert_eq!(next.title, "Sync"); + } + #[test] + fn an_all_day_entry_never_wins() { + let mut all_day = event("Offsite", -120, 600, Some("https://meet.jit.si/offsite")); + all_day.all_day = true; + let events = [ + all_day, + event("Standup", 10, 15, Some("https://meet.jit.si/standup")), + ]; + let next = next_joinable(&events, NOW, None).expect("a meeting"); + assert_eq!(next.title, "Standup"); + } + #[test] + fn imminent_covers_in_progress_and_the_quarter_hour_before() { + let soon = next_joinable( + &[event("Soon", 10, 30, Some("https://meet.jit.si/soon"))], + NOW, + None, + ) + .expect("a meeting"); + assert!(soon.is_imminent()); + + let later = next_joinable( + &[event("Later", 40, 30, Some("https://meet.jit.si/later"))], + NOW, + None, + ) + .expect("a meeting"); + assert!(!later.is_imminent()); + + let running = next_joinable( + &[event( + "Running", + -5, + 30, + Some("https://meet.jit.si/running"), + )], + NOW, + None, + ) + .expect("a meeting"); + assert!(running.is_imminent()); + } + #[test] + fn an_empty_calendar_has_nothing_to_join() { + assert_eq!(next_joinable(&[], NOW, None), None); + } + #[test] + fn a_named_join_skips_the_sooner_meeting() { + let events = [ + event("Sooner", 5, 30, Some("https://meet.jit.si/sooner")), + event("Design review", 60, 30, Some("https://meet.jit.si/design")), + ]; + let named = next_joinable(&events, NOW, Some("design review")).expect("a meeting"); + assert_eq!(named.title, "Design review"); + + // Matching is case- and order-insensitive over words, but every word + // has to appear. + assert!(next_joinable(&events, NOW, Some("REVIEW")).is_some()); + assert_eq!(next_joinable(&events, NOW, Some("design retro")), None); + } + #[test] + fn the_list_holds_every_candidate_best_first() { + let events = [ + event("Later", 90, 30, Some("https://meet.jit.si/later")), + event("No link", 5, 30, None), + event("Running", -5, 30, Some("https://meet.jit.si/running")), + event("Soon", 20, 30, Some("https://meet.jit.si/soon")), + ]; + let listed = joinable_meetings(&events, NOW, None); + let titles: Vec<&str> = listed.iter().map(|m| m.title.as_str()).collect(); + assert_eq!(titles, ["Running", "Soon", "Later"]); + // The head of the list is exactly what a bare "join" would take. + assert_eq!( + next_joinable(&events, NOW, None).map(|m| m.title), + Some("Running".to_string()) + ); + } + #[test] + fn a_matching_meeting_without_a_link_is_reported_by_name() { + // Two meetings called Testing, one with a link and one without: the + // list holds the joinable one and the outcome still names the other. + let events = [ + event( + "Testing", + 30, + 60, + Some("https://meet.google.com/abc-defg-hij"), + ), + event("Testing", 300, 60, None), + event("Retro", 20, 30, None), + ]; + let outcome = join_outcome(&events, NOW, Some("testing")); + assert_eq!(outcome.meetings.len(), 1); + assert_eq!(outcome.without_link, ["Testing"]); + // "Retro" did not answer to the name, so it is not a near-miss. + assert!(!outcome.without_link.contains(&"Retro".to_string())); + } + #[test] + fn near_misses_are_named_once_each() { + let events = [ + event("Standup", 10, 30, None), + event("Standup", 60, 30, None), + ]; + let outcome = join_outcome(&events, NOW, Some("standup")); + assert!(outcome.meetings.is_empty()); + assert_eq!(outcome.without_link, ["Standup"]); + } + #[test] + fn a_name_matches_across_diacritics() { + // What a Vietnamese user actually types. The file search has folded + // this way for a long time; the join tier now agrees with it. + let events = [ + event("Họp nhóm", 30, 60, Some("https://meet.jit.si/hop")), + event("Điện thoại", 90, 30, Some("https://meet.jit.si/dt")), + ]; + assert_eq!( + next_joinable(&events, NOW, Some("hop")).map(|m| m.title), + Some("Họp nhóm".to_string()) + ); + assert_eq!( + next_joinable(&events, NOW, Some("dien thoai")).map(|m| m.title), + Some("Điện thoại".to_string()) + ); + // And the other direction: typing the diacritics still works. + assert!(next_joinable(&events, NOW, Some("Họp")).is_some()); + } + #[test] + fn a_name_that_matches_nothing_produces_no_row() { + // This is what keeps "join two pdfs" a file search: the words are read + // as a name, no meeting answers to it, and the launcher shows nothing. + let events = [event("Standup", 5, 30, Some("https://meet.jit.si/standup"))]; + assert_eq!(next_joinable(&events, NOW, Some("two pdfs")), None); + } + #[test] + fn the_link_is_found_in_notes_as_well_as_the_url_field() { + let mut in_notes = event("Standup", 5, 15, None); + in_notes.notes = Some(ZOOM_NOTES.to_string()); + let next = next_joinable(&[in_notes], NOW, None).expect("a meeting"); + assert_eq!(next.provider, Provider::Zoom); + assert_eq!(next.provider_label, "Zoom"); + } +} diff --git a/core/ai/src/ollama.rs b/core/ai/src/ollama.rs index 0deba370..ad1e82c6 100644 --- a/core/ai/src/ollama.rs +++ b/core/ai/src/ollama.rs @@ -17,6 +17,8 @@ pub fn post_json(url: &str, body: &str, timeout_secs: u32) -> Option { command .arg("-sS") .arg("--fail") + .arg("--connect-timeout") + .arg(crate::chat::CONNECT_TIMEOUT_SECS.to_string()) .arg("--max-time") .arg(timeout_secs.to_string()) .arg("-H") diff --git a/core/ai/src/route.rs b/core/ai/src/route.rs index 679a406c..9fe5fa5b 100644 --- a/core/ai/src/route.rs +++ b/core/ai/src/route.rs @@ -2,7 +2,7 @@ //! the precedence is code, not convention: //! //! ```text -//! memory -> textop -> files -> explicit -> plan -> chat +//! memory -> join -> call -> textop -> files -> explicit -> plan -> chat //! ``` //! //! Deterministic tiers run first, most-precise first (memory and textop match @@ -17,12 +17,14 @@ use std::path::Path; use serde_json::json; -use crate::{explicit, files, memory, textops}; +use crate::{calling, explicit, files, meeting, memory, textops}; /// Route `input` (the `>` already consumed) and return the decision as JSON: -/// `{"route":"memory","feedback":...}` | `{"route":"textop","label":..., -/// "instruction":...}` | `{"route":"files"}` | `{"route":"explicit","call": -/// {tool,params}}` | `{"route":"plan"}` | `{"route":"chat"}`. +/// `{"route":"memory","feedback":...}` | `{"route":"join","name":...}` | +/// `{"route":"call","name":...,"modality":...}` | +/// `{"route":"textop","label":...,"instruction":...}` | `{"route":"files"}` | +/// `{"route":"explicit","call":{tool,params}}` | `{"route":"plan"}` | +/// `{"route":"chat"}`. /// The memory tier executes the command (it is the handler, not a preview). pub fn route_json( memory_path: &Path, @@ -37,6 +39,24 @@ pub fn route_json( if let Some(feedback) = memory::handle_command(memory_path, trimmed) { return json!({ "route": "memory", "feedback": feedback }).to_string(); } + // Above the planner, which reads "join the standup" as ADD an event called + // "the standup" - a confirm bar for a meeting that already exists. The + // shell resolves the name against the calendar and falls through to chat + // when nothing answers to it, so an unmatched name costs nothing. + if let Some(request) = meeting::join_query(trimmed) { + return json!({ "route": "join", "name": request.name }).to_string(); + } + // Same reasoning as `join`: asked to plan "call mom", a 7B model proposes + // adding an EVENT called "call mom". The shell resolves the name against + // the address book. + if let Some(request) = calling::call_query(trimmed) { + return json!({ + "route": "call", + "name": request.name, + "modality": request.modality.map(|modality| modality.id()), + }) + .to_string(); + } if let Some(op) = textops::parse(trimmed) { return json!({ "route": "textop", "label": op.label, "instruction": op.instruction }) .to_string(); @@ -100,6 +120,69 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn join_routes_ahead_of_the_planner() { + let path = temp_memory(); + let _ = std::fs::remove_file(&path); + const NOW: i64 = 1_754_000_000; + + let bare = decoded(&route_json(&path, "join", true, NOW)); + assert_eq!(bare["route"], "join"); + assert!(bare["name"].is_null()); + + // The phrasing that the planner used to turn into "add an event called + // Testing Meeting". + let named = decoded(&route_json(&path, "Join Testing Meeting", true, NOW)); + assert_eq!(named["route"], "join"); + assert_eq!(named["name"], "testing"); + + // Still routed with no model configured: it never needed one. + assert_eq!( + decoded(&route_json(&path, "join", false, NOW))["route"], + "join" + ); + + // "remember to join the standup" is a memory write, not a join. + let memory = decoded(&route_json( + &path, + "remember to join the standup", + true, + NOW, + )); + assert_eq!(memory["route"], "memory"); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn call_routes_ahead_of_the_planner() { + let path = temp_memory(); + let _ = std::fs::remove_file(&path); + const NOW: i64 = 1_754_000_000; + + // The phrasing the planner would otherwise turn into "add an event + // called call mom". + let bare = decoded(&route_json(&path, "call mom", true, NOW)); + assert_eq!(bare["route"], "call"); + assert_eq!(bare["name"], "mom"); + assert!( + bare["modality"].is_null(), + "unsaid, for the shell to default" + ); + + let named = decoded(&route_json(&path, "facetime sarah lee", true, NOW)); + assert_eq!(named["route"], "call"); + assert_eq!(named["name"], "sarah lee"); + assert_eq!(named["modality"], "face_time_video"); + + // "remind me to call mom @ 5pm" is a reminder, not a call: the line + // does not OPEN with the verb. + let reminder = decoded(&route_json(&path, "remind me to call mom @ 5pm", true, NOW)); + assert_eq!(reminder["route"], "explicit"); + + let _ = std::fs::remove_file(&path); + } + #[test] fn memory_wins_over_files_wording() { // "remember ..." containing file words must store a fact, not search. diff --git a/core/engine/src/normalize.rs b/core/engine/src/normalize.rs index ba6cc2ae..f0c7155e 100644 --- a/core/engine/src/normalize.rs +++ b/core/engine/src/normalize.rs @@ -1,26 +1,4 @@ -use unicode_normalization::UnicodeNormalization; -use unicode_normalization::char::is_combining_mark; +//! Moved to `look-matching` so the AI tiers fold text the same way search +//! does. Re-exported here to keep the engine's call sites unchanged. -pub(crate) fn normalize_for_search(input: &str) -> String { - // Fast path: pure ASCII avoids Unicode NFKD overhead - if input.is_ascii() { - let mut out = input.to_owned(); - out.make_ascii_lowercase(); - return out; - } - - let mut out = String::with_capacity(input.len()); - - for ch in input.nfkd() { - if is_combining_mark(ch) { - continue; - } - - match ch { - 'đ' | 'Đ' => out.push('d'), - _ => out.extend(ch.to_lowercase()), - } - } - - out -} +pub(crate) use look_matching::normalize_for_search; diff --git a/core/matching/Cargo.toml b/core/matching/Cargo.toml index 84914986..663aeddd 100644 --- a/core/matching/Cargo.toml +++ b/core/matching/Cargo.toml @@ -5,3 +5,6 @@ rust-version.workspace = true license.workspace = true authors.workspace = true version = "0.1.0" + +[dependencies] +unicode-normalization = "0.1" diff --git a/core/matching/src/lib.rs b/core/matching/src/lib.rs index 111db81b..8e06d7c8 100644 --- a/core/matching/src/lib.rs +++ b/core/matching/src/lib.rs @@ -1,3 +1,6 @@ +pub mod normalize; +pub use normalize::normalize_for_search; + pub struct PreparedQuery<'a> { raw: &'a str, chars: Vec, diff --git a/core/matching/src/normalize.rs b/core/matching/src/normalize.rs new file mode 100644 index 00000000..cf594eca --- /dev/null +++ b/core/matching/src/normalize.rs @@ -0,0 +1,71 @@ +//! Folding text so a search matches what a reader means. +//! +//! Lives in the matching crate, not the engine, because more than search needs +//! it: the AI tiers match a meeting title and a contact name against typed +//! text, and folding differently there would mean `hop` finds a FILE called +//! `Họp` while `join hop` finds nothing. + +use unicode_normalization::UnicodeNormalization; +use unicode_normalization::char::is_combining_mark; + +/// Lowercased, with diacritics stripped: `Họp` and `hop` compare equal, as do +/// `Café` and `cafe`. +/// +/// Vietnamese needs one rule Unicode does not give for free: `đ` is a letter in +/// its own right rather than a `d` with a mark, so NFKD leaves it alone and it +/// has to be mapped by hand. Without that, `dien thoai` misses `Điện thoại`. +pub fn normalize_for_search(input: &str) -> String { + // Fast path: pure ASCII avoids Unicode NFKD overhead + if input.is_ascii() { + let mut out = input.to_owned(); + out.make_ascii_lowercase(); + return out; + } + + let mut out = String::with_capacity(input.len()); + + for ch in input.nfkd() { + if is_combining_mark(ch) { + continue; + } + + match ch { + 'đ' | 'Đ' => out.push('d'), + _ => out.extend(ch.to_lowercase()), + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_is_just_lowercased() { + assert_eq!(normalize_for_search("Safari"), "safari"); + assert_eq!(normalize_for_search("main.go"), "main.go"); + } + + #[test] + fn vietnamese_folds_to_what_people_type() { + assert_eq!(normalize_for_search("Họp"), "hop"); + assert_eq!(normalize_for_search("Điện thoại"), "dien thoai"); + assert_eq!(normalize_for_search("Đà Nẵng"), "da nang"); + } + + #[test] + fn other_latin_marks_fold_too() { + assert_eq!(normalize_for_search("Café"), "cafe"); + assert_eq!(normalize_for_search("Müller"), "muller"); + assert_eq!(normalize_for_search("RÉSUMÉ"), "resume"); + } + + #[test] + fn scripts_without_case_or_marks_pass_through() { + // Han and kana have nothing to fold; the point is that they survive. + assert_eq!(normalize_for_search("会议"), "会议"); + assert_eq!(normalize_for_search("いぬ"), "いぬ"); + } +} diff --git a/docs/ai-action-contracts.md b/docs/ai-action-contracts.md index b2102cf6..6cdd4e88 100644 --- a/docs/ai-action-contracts.md +++ b/docs/ai-action-contracts.md @@ -42,7 +42,7 @@ Two deliberate exceptions: ONE ladder, shared by every shell so precedence cannot drift: ```text -memory -> textop -> files -> explicit -> plan -> chat +memory -> join -> call -> textop -> files -> explicit -> plan -> chat ``` Deterministic tiers run first, most precise first. `plan` appears only when a @@ -51,6 +51,15 @@ calls `look_ai_route(memory_path, input, model_available, now)` and switches on the returned decision. The memory tier has already executed when it answers (it is the handler, not a preview). +`join` and `call` sit above the planner for the same concrete reason: asked to plan "join the +standup", a 7B model proposes ADDING an event called "the standup", so a +meeting that already exists turns into a confirm bar for a duplicate; "call +mom" fares the same way. Both tiers are fixed grammars (`meeting::join_query`, +`calling::call_query`), need no model, and hand the shell a name to resolve +against the calendar or the address book - the shell owns those, since the +stores are platform code. Both end in the same place: a list of things to open, +and one URL. + ## 2. The planner wire format (`core/ai/src/plan.rs`, `planner.rs`) The model emits JSON forced by a schema in Ollama's `format` field, so an diff --git a/docs/ai-session.md b/docs/ai-session.md index f905973c..84008574 100644 --- a/docs/ai-session.md +++ b/docs/ai-session.md @@ -72,13 +72,69 @@ the newest turns that fit ~2500 tokens (`core/ai/src/context.rs`), so a long chat stays coherent without a summarizer and an old conversation resumes with bounded weight. -Empty AI mode shows the recent conversations: typing searches them (title + -content), and **⌘ + a home-row key** (⌘A, ⌘S, ⌘D ... matching the chip on each -row) opens one - as does highlighting it with Tab/↑↓ and pressing Enter, or -clicking. The full transcript restores and the chat picks up with context; -typing a real prompt starts a fresh conversation instead. A bare number is NOT a -shortcut here - numbers answer the disambiguation list only. While browsing the list, no model calls fire; Enter drives -everything (instant `@` forms still preview live). +Empty AI mode shows the **10 most recent** conversations: typing searches them +(title + content), and **⌘ + a digit** (⌘1 … ⌘9 then ⌘0 for the tenth, matching +the chip on each row) opens one - as does highlighting it with Tab/↑↓ and +pressing Enter, or clicking. The full transcript restores and the chat picks up +with context; typing a real prompt starts a fresh conversation instead. + +Ten is a ceiling, not a preference: a ⌘ chord is one keypress, so there is no +⌘10 and no eleventh chip to hand out. The list is capped at the same number +(`AppConstants.Launcher.AISessions.jumpKeyLimit`), so it never grows a row no +chord can reach. Older conversations stay stored and stay findable by typing, +then Tab/↑↓ and Enter. + +The digits are free because AI mode hides the running-apps strip that owns ⌘1-9 +everywhere else; both handlers gate themselves, so only one can claim the chord. +⌘0 is the one real collision: while the sessions list is on screen it opens the +tenth row instead of the "Actual Size" zoom reset, which stays available +everywhere else. + +**`@name` attaches a file.** The suggestion popup is two columns: matches with +their abbreviated paths on the left, a preview of the HIGHLIGHTED file on the +right (`FilePreview`, the same text/Quick Look pair the result pane uses). It +follows the highlight rather than the top match, because with nothing +highlighted Enter still sends the message, and previewing a file the keyboard is +not pointing at would suggest otherwise. Six files called `main.go` is a normal +result, so the path is on every row, and the attachment capsule in the +transcript carries its folder for the same reason: a transcript outlives the +moment it was written in. + +**Shift+Enter** inserts a line break instead of sending. The input is the same +`SmoothCaretTextField` as the search bar, so multiline is switched on only for AI +mode (`allowsMultiline`): it wraps and grows to 6 lines and stops there, with the +caret scrolled into view past that. Three things follow from that and must stay +true - a field editor routes Shift+Return to `insertNewline:` as well, so the +delegate decides from the EVENT's modifiers, never from the selector alone (the +selector-only version sent the message instead of breaking the line); the field +editor takes its line mode at begin-editing, so flipping the mode mid-edit +restarts editing and restores the caret; and the caret layer measures x from the +start of the CARET'S line, not from glyph 0, or it walks off the right edge on +every line but the first. + +**⌥↑/⌥↓** walks the prompt history (shell style). It used to be ⇧↑/⇧↓, which the +multiline composer needs for selecting text. Two constraints pinned the +replacement: ⌃↑/⌃↓ are Mission Control and Application Windows at the +WindowServer level, so the app never receives them; and the handler must sit +above the monitor's modifier passthrough, which hands every ⌘/⌥/⌃ combo straight +to the system. AI mode consumes the chord even at a boundary, so a press at the +oldest entry stays put instead of moving the caret by paragraph. + +**⌘D** deletes the highlighted session (same delete as ⌘⌫ and the row's trash +button, undoable from the banner). In AI mode the chord stops there rather than +falling through to the main bar's "trash the selected file": with a conversation +open there is simply no delete target. **⌘H** opens the help screen from +anywhere in AI mode - the panel branch puts help ahead of the session screen, so +the mode is paused rather than left, and ⌘H again (or Esc, or typing) puts the +conversation straight back. Help is filtered by topic capsules (All / Main / AI +/ Prefixes / Command); arriving from AI mode opens on the **AI** capsule, so the +assistant's keys are the first thing on screen rather than something to scroll +for. The capsules are clickable from any topic, and the screen re-opens on the +topic it was entered from, not the last one clicked. + +A TYPED bare number is still not a session shortcut - typed numbers answer the +disambiguation list only. While browsing the list, no model calls fire; Enter +drives everything (instant `@` forms still preview live). ## Markdown in answers diff --git a/docs/features.md b/docs/features.md index 252479db..ba6f9569 100644 --- a/docs/features.md +++ b/docs/features.md @@ -51,6 +51,8 @@ This document tracks what `look` supports today and what is planned next. - **the `>` session**: type `>` to switch the panel into a conversation - actions, questions, and streamed answers stack together. `Esc` leaves, `Cmd+Z` undoes, `Cmd+.` stops a generation without ending the session. Past conversations are listed, searchable, and resumable - **calendar and reminders**: add, move, cancel, complete, remove, snooze, and block focus time, in plain language ("move my dentist to friday"). Every change previews first and confirms with `Enter`, then `Cmd+Z` undoes it. Adding an event that already sits on that day says so instead of quietly duplicating it - **`@` for exact times**: `>add lunch @ 1pm` skips the model entirely - instant, deterministic, and works with no capable model configured +- **join a meeting**: typing `join` in `>` opens the next meeting and says which one; in the main bar it pins a "Join " row for the next Teams, Zoom, Google Meet, Webex, Jitsi, GoToMeeting, or Whereby meeting on your calendar, and `Enter` opens it. Name one to skip past the sooner one (`join standup`, `join design review`); a name that matches nothing shows no row, which is what keeps `join two pdfs` an ordinary file search. Looks two days ahead, so a meeting tomorrow says "tomorrow 14:30" rather than counting minutes. Deterministic and model-free: the invite already carries its join link, so Look reads it out of the event rather than calling any API. A meeting already under way beats one starting sooner. Online accounts work through macOS Calendar (System Settings > Internet Accounts); Look itself makes no network call +- **call and message**: `call mom`, `facetime sarah`, `message alex`, `call mom on iphone`. Matches the name against Contacts and opens FaceTime or Messages by URL scheme - no API, no network. Always lists what it found before anything rings, so a wrong pick never calls the wrong person - the row you read is the confirmation. Works in `>` and in the main bar, where each way to reach them is its own row. Needs Contacts access (Settings > AI > Permissions) - **no prefix needed**: typing an instruction in the main bar works too. The plan appears as the first result row and one `Enter` runs it - **file recall**: "pdfs from last week", "files added to desktop" search your index by type, time, and place - **text-ops**: "summarize", "translate to german", "make this shorter" transform whatever you copied. Pick a file first (`Cmd+P`), or `@`-mention one while typing, and they transform that file instead. Text files, source code, and PDFs; an oversized file says how much of it was read rather than quietly summarizing the first part. A PDF that is a scan, is password-protected, or decodes to junk is refused by name - summarizing garbage would read exactly like a real answer @@ -81,6 +83,7 @@ This document tracks what `look` supports today and what is planned next. - click on an icon also switches; hover shows app name + shortcut tooltip; active app has an accent ring - toggled on/off via `Settings > Appearance > Running Apps`. Persisted as `running_apps_placement` in `~/.look.config` (`none` = off, any other value = on; legacy `top`/`right`/`bottom` still load as "on"). The window is a single fixed size and never resizes for the row - off hides the row and disables the activation shortcut +- AI mode (`>`) hides the row too, and hands `Cmd+1`..`Cmd+9` plus `Cmd+0` to the conversation list: the digit opens the session carrying that chip. Ten chips is the ceiling (a `Cmd` chord is one keypress), so the list shows ten and older sessions are found by typing ### Super actions diff --git a/docs/substage-takeaways-plan.md b/docs/substage-takeaways-plan.md new file mode 100644 index 00000000..61c9bc37 --- /dev/null +++ b/docs/substage-takeaways-plan.md @@ -0,0 +1,197 @@ +# Four takeaways from Substage - plan + +> **Status: PLANNED.** Nothing built. Written 2026-08-16 after looking at +> [Substage](https://selkie.design/substage/), a natural-language command bar +> for Finder selections. + +Substage overlaps Look on purpose-built ground: instant actions that bypass the +model for common cases, and safety over ambition. Those parts confirm the +existing doctrine rather than teaching it. Four things it does that Look does +not, ordered by value. + +**One thing Look must not copy**, stated once here because it shapes all four: +Substage has a model WRITE a shell command that the user audits. Look's +contract (`ai-action-contracts.md`) is the opposite - the model is another +parser that "can never reach an execution path the deterministic parser can't". +Auditing generated shell asks the user to review a language they may not read. +Every item below keeps generation out of the execution path. + +--- + +## 1. Predict what `/shell` will do, before it does it + +**The gap.** `/shell` is the one thing Look does that mutates anything, reaches +the network, and asks nobody. Everything else destructive either previews and +confirms (calendar tools, Empty Trash) or is recoverable by design (`Cmd+D` +moves a file to the Trash unconfirmed, because the Trash is the undo). +`LauncherView+CommandMode.runCommandModeAction` runs it on Enter with a single +cue: a warning when the input contains `sudo`. That is a string match, not an +understanding, and it says nothing about `rm -rf`, `curl | sh`, `> file`, or +`git push`. + +**What to take.** Substage's headline is a prediction that CATEGORISES an +operation: what will be "created, changed, moved, deleted, or sent over the +network". The network category earns its place because Look is otherwise +precise about which features touch the network - web answers, suggestions, and +the speed test do; local search, indexing, and the AI action tiers do not. A +shell command is the one path where the user cannot tell which it is. + +**Design.** A `core/shell` crate: text in, ordered effects out. No model, no +execution, fully testable. + +```rust +pub struct ShellPrediction { + pub effects: Vec, // ordered by consequence, worst first + pub unparsed: bool, // saw something it could not classify +} + +pub enum Effect { + Creates { path: String }, // >file, tee, mkdir, touch + Changes { path: String }, // >>file, sed -i, chmod, mv target + Deletes { path: String }, // rm, rmdir, trash, mv source + Network { host: String }, // curl, wget, ssh, scp, git push/pull + Elevates, // sudo, doas + PipesToShell, // curl ... | sh - the one that matters most + RunsUnknown { binary: String }, +} +``` + +A lexer for words, quotes, pipes, redirects and `&&`/`;`, then a table keyed on +each segment's leading binary. Not a parser for all of POSIX sh. + +Two rules that carry the whole feature: + +- **`unparsed` must be honest.** A prediction that silently under-reports is + worse than none, because it launders the command as safe. +- **Order by consequence, not position.** `PipesToShell` and `Elevates` first, + then `Deletes`, then `Network`. A user scanning one line meets the worst thing + first. + +```text +Will run: curl -fsSL https://get.example.sh | sh + network get.example.sh + runs whatever that host returns +``` + +Enter still runs it. The preview informs; it never blocks. + +**Steps.** (1) `core/shell` with a fixture corpus asserting effects AND +`unparsed` - a convert, an `rm -rf` with a variable, a piped installer, a `git +push`, a heredoc. (2) `look_shell_predict_json`. (3) The preview block in the +`/shell` panel. (4) `features.md`, `user-guide.md`, and a line in +`ai-action-contracts.md` saying where this sits (it is not a ladder tier - +`/shell` is command mode, not AI mode). + +**Out of scope.** Generating commands from language; blocking or sandboxing; +pretending to know what `./deploy.sh` does - name the binary, say the effects +are unknown, stop. + +--- + +## 2. Take the target from the OS, not from the query + +**The gap.** Substage acts on whatever is selected in Finder. Look's text-ops +need `Cmd+P` picks or an `@`-mention first, so "summarize this" with a file +already selected in Finder does nothing until the user re-selects it inside +Look. + +**Design.** A new rung on the ladder `TextOpSource.resolve` already implements, +below the explicit ones so nothing changes for anyone using them: + +```text +@-mention > Cmd+P picks > frontmost Finder selection > clipboard +``` + +Read via Apple events (`tell application "Finder" to get selection`), which +needs Automation - the same grant Empty Trash already asks for, and one more +entry in `PermissionItem.all`. + +**Two rules.** + +- **Read on demand, never poll.** Look asks Finder what is selected at the + moment a text-op needs a target. A background watcher of what the user has + selected is surveillance, not a feature. +- **Say where the target came from.** The bar shows `from Finder: report.pdf`, + because an implicit target the user cannot see is worse than no target: it + transforms the wrong file silently. + +**Steps.** `FinderSelectionService` (Swift, Apple events); extend +`TextOpSource.resolve` with the new rung and its tests; show the source in the +attachment bar; Automation chip in the permissions row; docs. + +**Out of scope.** Any window other than the frontmost Finder window; watching +selection changes; using it for anything but text-ops and file ops. + +--- + +## 3. Replay a command onto whatever is picked now + +**Mostly already true.** Substage's up arrow replays a command against +different files. Look resolves the target at SUBMIT, not at recall: +`ActionController.textOpSource()` runs inside the route dispatch, so `Opt+Up` to +"summarize", then picking another file, already transforms the new one. The +`@`-token is consumed out of the recalled text (`MentionQuery.consume`), so +history holds clean prose with no stale filename in it. + +**What is actually missing: visibility.** Nothing tells the user what a recalled +command is about to act on until after it runs. The whole of this item is one +line in the composer: + +```text +summarize → report.pdf (picked) +``` + +Resolve `TextOpSource` as the input changes and render its target, so the answer +to "which file will this hit" is on screen before Enter, not after. + +**Steps.** Expose the resolved source as a published value; render it beside the +input; nothing else. If item 2 ships, this is also what makes an implicit Finder +target safe. + +--- + +## 4. Rules: teach it your shorthand + +**The gap.** Substage lets a user teach it "shorthand, folders, formats, and +conventions you use every day". Look's `memory` stores durable FACTS for chat +context, but nothing the deterministic tiers read. So "my exports folder" means +nothing to the file tier, and every user's vocabulary is the one the lexicon +shipped with. + +**Design.** Typed rules rather than free text, stored the way memory already is +- tier-1 only, user-written, **never model-written**, for the reason +`ai-action-contracts.md` already gives about memory: a weak planner must not be +able to pollute durable state. + +```text +remember exports means ~/Work/exports +remember convert means 1080p mp4 +``` + +Expansion runs in core BEFORE the tier-1 grammars parse, so every tier inherits +it at once: file recall gets a location, text-ops get a format, `call` gets a +nickname. + +**The rule that keeps it safe.** Aliases expand only where a slot is EXPECTED, +never globally. Otherwise a user with a folder called "mom" turns `call mom` +into a file search. Slot-scoped expansion is the difference between a shorthand +system and a booby trap. + +**Steps.** Rules store in core beside `memory`; an expansion pass in `route.rs` +with tests for the shadowing case; FFI; a Settings list to see and remove rules +(a rule you cannot find is a rule you cannot fix); docs. + +**Out of scope.** Model-written rules; rules that expand to commands rather than +values - that is generation again, and item 1's reasoning applies. + +--- + +## Suggested order + +1. **`/shell` prediction** - the biggest safety gap in Look today, and a + differentiator Look has already earned by making the no-network promise. +2. **Target visibility** (item 3) - one line of UI, and a prerequisite for + making item 2 safe. +3. **Finder selection** - removes a whole step from text-ops; costs a grant. +4. **Rules** - the largest surface, and the one whose value depends most on + users actually having conventions worth teaching. diff --git a/docs/user-guide.md b/docs/user-guide.md index fe9c0554..8c28bc20 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -44,6 +44,8 @@ Look is designed to need as few macOS permissions as possible: - **Network access** is used for explicit actions - `t"` translation, `tw"` dictionary lookup, and `Cmd+Enter` web search - and, when **AI features** are enabled (macOS, on by default), for live Google search suggestions and the DuckDuckGo/Wikipedia answer card as you type. The AI model runs wherever you point it. Apple Intelligence is on-device and Ollama defaults to `localhost`, so by default no prompt leaves the machine. If you change `ollama_host` to a non-loopback address, or select a cloud-routed Ollama model (a `-cloud` tag, which the local daemon proxies to Ollama's service), then **your prompt travels over the network to that provider**. Separately from the prompt, your calendar, clipboard, and remembered facts are attached only when inference is on this machine; for anything remote they are withheld until you turn on `ai_allow_remote_context` in Settings. Turn the AI/web features off by setting `ai_enabled = false` in `~/.look.config` (or via Settings). Local search and indexing never make network calls. - **Finder Automation** is requested only when you empty the Trash (`Cmd+D` on the pinned Trash folder). The Trash is protected by macOS, so Look asks Finder to empty it; macOS prompts once, and you can manage it under `System Settings > Privacy & Security > Automation`. Moving individual files to the Trash needs no permission. +**Settings > AI > Permissions** lists every capability that needs OS access (Calendar, Reminders), what Look does with it, and whether it's connected. **Grant all** asks for the outstanding ones in turn; macOS has no single "allow everything" prompt, so each still appears on its own. Once a permission has been answered - granted or denied - only System Settings can change it, so those rows link straight to the right pane. Look also asks the first time you use a feature that needs access, which is why `join` may prompt for Calendar. + If macOS prompts for permission during an action you didn't trigger, that's a bug - please [file an issue](https://github.com/kunkka19xx/look/issues). ## Core workflow @@ -273,7 +275,7 @@ plain sway, X11 without KWin) Look stays clear glass and `Blur Opacity` applies only when you have set a background image. Driving blur from your own compositor config still works; Look's request is additional, not exclusive. -**Running Apps**: a switch that shows running-app icons in the right half of the search bar. When on, the search field shrinks to the left half and the running apps fill the right half (right-aligned, growing leftward as more apps open). Each icon has a corner number badge; pressing the modifier + the badge digit on the home screen activates that app - `Cmd+1`..`Cmd+9` on macOS, `Alt+1`..`Alt+9` on Linux and Windows. When off, the search bar spans the full width and the switcher shortcut is disabled. The launcher window stays the same size either way. +**Running Apps**: a switch that shows running-app icons in the right half of the search bar. When on, the search field shrinks to the left half and the running apps fill the right half (right-aligned, growing leftward as more apps open). Each icon has a corner number badge; pressing the modifier + the badge digit on the home screen activates that app - `Cmd+1`..`Cmd+9` on macOS, `Alt+1`..`Alt+9` on Linux and Windows. When off, the search bar spans the full width and the switcher shortcut is disabled. AI mode (`>`) hides the row regardless of this setting, and its digits open listed conversations instead. The launcher window stays the same size either way. Behavior: @@ -433,6 +435,13 @@ Note: `Settings Blur` is stored as local app UI state (UserDefaults) and is not - `:cmd` (e.g. `:calc 2+2`, `:kill chrome`, `:sys`, `:todo`, `:speed`): jump to a command directly from the home screen - `Cmd+1`..`Cmd+7`: in command mode, direct command switch (`calc`, `pomo`, `todo`, `speed`, `kill`, `shell`, `sys`) - `Cmd+1`..`Cmd+9` (macOS) / `Alt+1`..`Alt+9` (Linux, Windows): on the home screen, activate the running-app whose badge shows that digit, when `Running Apps` is on. Badge labels are ergonomic, not strictly positional - see Settings → Appearance → Running Apps +- `Option+Up` / `Option+Down` in AI mode (`>`): walk your recent prompts, like a shell history. `Shift+Up` / `Shift+Down` select text in the message instead +- `Shift+Enter` in AI mode (`>`): new line in the message instead of sending. The box grows to 6 lines and stops there. Elsewhere `Shift+Enter` still opens all picked files +- `join` (or `join meeting`, `join my next meeting`, or `join `): pins a "Join " row for the next Teams / Zoom / Meet / Webex / Jitsi / GoToMeeting / Whereby meeting in your calendar; Enter opens the link. Works in the main bar and in `>` AI mode. Looks two days ahead. Needs the account in macOS Calendar (System Settings → Internet Accounts), since Look reads the OS's calendar and makes no network call of its own +- `call ` / `facetime ` / `message ` in AI mode (`>`): finds the person in Contacts and opens FaceTime or Messages. `call mom on iphone` dials through your iPhone. A bare `call` means FaceTime audio, the one that works with no iPhone nearby. Look always lists what it found first; `Enter` on the highlighted row places the call +- `Cmd+D` in AI mode (`>`): delete the highlighted conversation (same as `Cmd+Delete`; undo from the banner with `Cmd+Z`) +- `Cmd+H` in AI mode (`>`): open the help screen on its **AI** topic without leaving the conversation. `Cmd+H`, `Esc`, or typing returns to it. The help screen's topic capsules (All / Main / AI / Prefixes / Command) also switch by click +- `Cmd+1`..`Cmd+9` and `Cmd+0` in AI mode (`>`): open the listed conversation carrying that chip (`Cmd+0` is the tenth). The running-apps row is hidden on the AI screen, so the digits mean sessions there, and `Cmd+0` opens the tenth session rather than resetting the UI scale while the list is up. The list stops at ten because a `Cmd` chord is a single keypress; older conversations are found by typing, then Tab/arrows and Enter - `Cmd+` (macOS) / `Alt+` (Linux, Windows): on the empty home screen, fire the super action with that highlighted letter (`B` Bluetooth, `W` Wi-Fi, `T` Theme, `K` Keep Awake, `S` Screensaver, `M` Mic, `P` play/pause, `R` Restart, `D` Shut Down), when `Super Actions` is on - `Space` / `R` / `P` (inside `/pomo`): start/pause session, reset, toggle music play/pause - `Cmd+N` / `Cmd+S` (inside `/todo`): switch Tasks/Stats page, save changes