feat: rank built-in commands alongside applications

This commit is contained in:
Valdemar Lorenzen
2026-10-15 11:23:50 +02:00
parent 31c651ecb1
commit e0f17c6339
7 changed files with 131 additions and 43 deletions
+5 -3
View File
@@ -69,13 +69,13 @@ Applications are discovered in `/Applications`, `/System/Applications`, `/System
Discovery supports both standard (`APPL`) and XPC-backed (`XPC!`) `.app` bundles, including Apple Passwords, provided they contain a valid executable.
Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches put favorites first, then order by successful launch count, most recent launch, and alphabetically. Neither favorites nor usage promote a weaker text match above a stronger one or change which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher.
Search is case- and accent-insensitive for applications and built-in commands alike. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches put favorites first, then order by launch count, most recent launch, and alphabetically. Neither favorites nor usage promote a weaker text match above a stronger one or change which entries match. Hover over an application to see its path. Application launch failures appear in a native alert without dismissing the launcher.
Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code.
With an empty search, favorites appear first, separated from the remaining applications by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group.
With an empty search, favorites appear first, separated from the remaining entries by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications and commands are ordered together by launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity or stable built-in command identity and survive restarts; an application and a command with the same name remain independent. Successful application launches and opening a command through Conjure count; submitting translations inside a command does not. Entries without history remain alphabetical within their group.
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
Background discovery preserves the selected entry even if its row changes. If that entry disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
@@ -83,6 +83,8 @@ Applications with identical display names show a muted containing-folder hint be
Search for **Conjure Translate**, labeled **Command**, and open it with Return, double-click, or **Open Command**. The launcher fades into an English → Japanese translation tool; the existing search bar becomes the English input and retains the launcher's placeholder styling. Escape returns to the previous search.
The command participates in the same text matching, usage ranking, and favorites as applications (`ct` matches its initials). Return, double-click, **Open Command**, numbered shortcuts, and **Actions → Open** all record an opening. Its Actions menu offers **Open**, **Favorite / Remove Favorite**, and **Reset ranking**, but not Finder, Quit, or Uninstall. Reset ranking clears usage without removing its favorite status.
Choose **MyMemory (no key)** or **OpenAI · gpt-6-luna** from the provider dropdown. MyMemory is the initial default, and the selection is saved locally. Switching providers cancels pending work and clears the previous translation; there is no automatic fallback. Press Return from the English input or click **Translate** in the bottom-right launcher-style action pill, beside its Return key badge, to send text to the selected provider.
[MyMemory](https://mymemory.translated.net/doc/spec.php) requires no account or key. Anonymous use allows 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php). Oversized input is rejected before sending; quota and service errors appear in the tool. Text is sent to a third-party translation service: avoid sensitive content.
@@ -13,6 +13,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
]
private var visibleEntries: [Int] = []
private var isCommand = false
private var selectedEntry: Int? = 0
private var rowTopConstraints: [NSLayoutConstraint] = []
private var heightConstraint: NSLayoutConstraint?
@@ -129,6 +130,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let pill = anchor.superview ?? anchor
_ = view
applicationLabel.stringValue = application.name
isCommand = application.command != nil
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
@@ -201,7 +203,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter { query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil }
visibleEntries = entries.indices.filter {
(!isCommand || $0 == 0 || $0 == 1 || $0 == 3)
&& (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil)
}
selectedEntry = visibleEntries.first
for index in entries.indices {
let position = visibleEntries.firstIndex(of: index)
+29 -27
View File
@@ -10,7 +10,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let sectionLabel = NSTextField(labelWithString: "Applications")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory
private var applications: [Application] = []
private var applications: [Application] = Application.commands
private var results: [Application] = []
private var calculation: CalculatorResult?
private var isCurrencyCalculation = false
@@ -32,10 +32,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var launcherViews: [NSView] = []
private var launcherFrameBeforeReview: NSRect?
private var translation: TranslationController?
private var showsTranslationCommand = true
private var translationCommandRow: Int? {
calculation == nil && showsTranslationCommand ? results.count : nil
}
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history
@@ -74,8 +70,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
window.launchNumber = { [weak self] index in
guard let self else { return }
if index == self.translationCommandRow { self.showTranslation() }
else if self.results.indices.contains(index) { self.openApplication(self.results[index]) }
if self.results.indices.contains(index) { self.openApplication(self.results[index]) }
}
window.dragEnded = { [weak self] in
guard let self else { return }
@@ -300,8 +295,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionsButton.keyEquivalentModifierMask = .command
actionsButton.target = self
actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Application actions")
actionsButton.toolTip = "Actions for the selected application (Command-K)"
actionsButton.setAccessibilityLabel("Entry actions")
actionsButton.toolTip = "Actions for the selected application or command (Command-K)"
actionsButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = ShortcutIndicator("↵")
@@ -375,7 +370,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}.value
guard let self else { return }
guard !Task.isCancelled else { return }
self.applications = applications
self.applications = applications + Application.commands
self.updateLocationLabels()
self.discoveryTask = nil
self.updateResults(preservingSelection: true)
@@ -407,9 +402,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateLocationLabels() {
var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count)
for application in applications { nameCounts[application.name, default: 0] += 1 }
for application in applications where application.command == nil { nameCounts[application.name, default: 0] += 1 }
var groups: [String: [(url: URL, folder: URL)]] = [:]
for application in applications where (nameCounts[application.name] ?? 0) > 1 {
for application in applications where application.command == nil && (nameCounts[application.name] ?? 0) > 1 {
groups[application.name, default: []].append(
(application.url, application.url.deletingLastPathComponent())
)
@@ -458,9 +453,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
detail: "\(answer.detail) · \(cacheStatus)"
)
}
showsTranslationCommand = query.split(whereSeparator: \.isWhitespace).allSatisfy {
"Conjure Translate".localizedStandardContains(String($0))
}
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil {
results = []
@@ -472,7 +464,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
: calculation == nil ? "Applications & Commands" : "Calculator"
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
: calculation == nil ? "Applications" : "Calculator result")
: calculation == nil ? "Applications and commands" : "Calculator result")
tableView.reloadData()
if resultCount > 0 {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
@@ -486,8 +478,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
emptyLabel.stringValue = "Finding applications…"
statusLabel.stringValue = "Finding applications…"
} else {
emptyLabel.stringValue = applications.isEmpty ? "No applications found" : "No matching applications"
statusLabel.stringValue = "\(results.count) applications" + (showsTranslationCommand ? " · 1 command" : "")
emptyLabel.stringValue = "No matching applications or commands"
let commandCount = results.lazy.filter { $0.command != nil }.count
statusLabel.stringValue = "\(results.count - commandCount) applications · \(commandCount) commands"
}
updateOpenButton()
(window as? LauncherPanel)?.sampleNumberHints()
@@ -519,7 +512,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true
}
private var resultCount: Int { calculation == nil ? results.count + (showsTranslationCommand ? 1 : 0) : 1 }
private var resultCount: Int { calculation == nil ? results.count : 1 }
private func moveSelection(by offset: Int) {
guard translation == nil else { return }
@@ -531,12 +524,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateOpenButton() {
let canAct = !isLaunching && launcherFrameBeforeReview == nil && translation == nil && tableView.selectedRow >= 0
openButton.title = calculation == nil ? (tableView.selectedRow == translationCommandRow ? "Open Command" : "Open Application") : "Copy Answer"
openButton.title = calculation == nil
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
? "Open Command" : "Open Application") : "Copy Answer"
openButton.toolTip = calculation == nil
? "Open the selected entry (Return). Use ↑ and ↓ to select."
: "Copy the answer to the clipboard (Return)."
openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil }
?? (results.indices.contains(tableView.selectedRow) || tableView.selectedRow == translationCommandRow))
?? results.indices.contains(tableView.selectedRow))
actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow)
}
@@ -574,11 +569,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = calculation.detail
} else if row == translationCommandRow {
cell.textField?.stringValue = "Conjure Translate"
cell.textField?.setAccessibilityLabel(nil)
} else if results[row].command != nil {
let entry = results[row]
cell.textField?.stringValue = entry.name
cell.textField?.setAccessibilityLabel(history.isFavorite(entry) ? "\(entry.name), Favorite" : nil)
cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = "Command"
cell.kindLabel.stringValue = history.isFavorite(entry) ? "★ Command" : "Command"
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = "Translate English to Japanese"
@@ -723,7 +719,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func openSelection() {
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if tableView.selectedRow == translationCommandRow { showTranslation(); return }
if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
@@ -749,7 +744,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
private func openApplication(_ application: Application) {
guard !isLaunching else { return }
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let command = application.command {
switch command {
case .translate: showTranslation()
}
history.recordLaunch(of: application)
return
}
(window as? LauncherPanel)?.resetNumberHints()
isLaunching = true
updateOpenButton()
+16 -3
View File
@@ -1,6 +1,11 @@
import Foundation
public struct Application: Identifiable, Sendable {
public enum Command: String, CaseIterable, Sendable {
case translate
}
public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command?
public var id: URL { url }
public let url: URL
public let name: String
@@ -12,8 +17,16 @@ public struct Application: Identifiable, Sendable {
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
}
fileprivate init(canonicalURL: URL, name: String) {
url = canonicalURL.hasDirectoryPath
public init(command: Command) {
let name = switch command {
case .translate: "Conjure Translate"
}
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
}
fileprivate init(canonicalURL: URL, name: String, command: Command? = nil) {
self.command = command
url = command != nil || canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name
searchableName = folded(name)
@@ -178,5 +191,5 @@ func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
let order = lhs.name.localizedStandardCompare(rhs.name)
if order != .orderedSame { return order == .orderedAscending }
if lhs.name != rhs.name { return lhs.name < rhs.name }
return lhs.url.path < rhs.url.path
return lhs.url.absoluteString < rhs.url.absoluteString
}
+4 -2
View File
@@ -18,7 +18,8 @@ public final class LaunchHistory {
self.defaults = defaults
self.now = now
for (identifier, value) in defaults.dictionary(forKey: Self.storageKey) ?? [:] {
guard let url = URL(string: identifier), url.isFileURL,
guard let url = URL(string: identifier),
url.isFileURL || Application.commands.contains(where: { $0.id == url }),
let record = value as? [String: Any],
let count = record["count"] as? Int, count > 0,
let lastLaunch = record["lastLaunch"] as? Date,
@@ -26,7 +27,8 @@ public final class LaunchHistory {
usage[url] = Usage(count: count, lastLaunch: lastLaunch)
}
for identifier in defaults.stringArray(forKey: Self.favoritesStorageKey) ?? [] {
guard let url = URL(string: identifier), url.isFileURL else { continue }
guard let url = URL(string: identifier),
url.isFileURL || Application.commands.contains(where: { $0.id == url }) else { continue }
favorites.insert(url)
}
}
+1 -7
View File
@@ -1,7 +1 @@
- [x] add a translation "command"
- it should appear in the list as a "Command" instead of an "Application" (entry type/kind)
- it should be named/titled "Conjure Translate"
- applying the open action on the command will cause the launcher to morph into a translation tool (ui transition like the uninstall confirmation view)
- the search bar becomes the translate input
- first pass should translate from english to japanese
- it should use gpt-6-luna as the translation unit (requires the user to insert an api key)
- [ ] improve on the conjure translation command
@@ -0,0 +1,70 @@
import AppKit
import ConjureKit
import Foundation
import Testing
@testable import Conjure
@Test @MainActor func commandsShareUsageFavoritesAndTextRankingWithApplications() {
let suite = "ConjureCommandRanking.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var now = Date(timeIntervalSinceReferenceDate: 12_000)
let history = LaunchHistory(defaults: defaults, now: { now })
let command = Application(command: .translate)
let app = Application(url: URL(fileURLWithPath: "/Applications/Conjure Test.app"), name: "Conjure Test")
let sameNameApp = Application(url: URL(fileURLWithPath: "/Applications/Conjure Translate.app"), name: command.name)
let entries = [app, sameNameApp, command]
history.recordLaunch(of: app)
now += 1
history.recordLaunch(of: command)
#expect(history.suggestions(in: entries).first?.id == command.id)
#expect(history.results(for: "conjure", in: entries).first?.id == command.id)
#expect(history.results(for: "ct", in: entries).first?.id == command.id)
history.recordLaunch(of: app)
#expect(history.suggestions(in: entries).first?.id == app.id)
#expect(history.results(for: "Conjure Translate", in: entries).first?.id == command.id)
history.toggleFavorite(command)
#expect(history.suggestions(in: entries).first?.id == command.id)
#expect(!history.isFavorite(sameNameApp))
let reloaded = LaunchHistory(defaults: defaults, now: { now })
#expect(reloaded.isFavorite(command))
#expect(reloaded.suggestions(in: entries).first?.id == command.id)
reloaded.toggleFavorite(command)
#expect(reloaded.results(for: "conjure", in: entries).first?.id == app.id)
#expect(reloaded.results(for: "Conjure Translate", in: entries).first?.id == command.id)
reloaded.recordLaunch(of: sameNameApp)
reloaded.resetRanking(of: command)
let afterReset = LaunchHistory(defaults: defaults, now: { now })
#expect(afterReset.suggestions(in: entries).first?.id == app.id)
#expect(afterReset.results(for: "Conjure Translate", in: entries).first?.id == sameNameApp.id)
}
@Test @MainActor func openingCommandByReturnAndNumberShortcutRecordsUsage() throws {
_ = NSApplication.shared
let suite = "ConjureCommandOpening.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let now = Date(timeIntervalSinceReferenceDate: 13_000)
let history = LaunchHistory(defaults: defaults, now: { now })
let command = Application(command: .translate)
let competitor = Application(url: URL(fileURLWithPath: "/Applications/Alpha.app"), name: "Alpha")
history.recordLaunch(of: competitor)
let launcher = LauncherWindowController(history: history)
let content = try #require(launcher.window?.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "ct"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first)
#expect(table.numberOfRows == 1)
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
#expect(history.suggestions(in: [command, competitor]).first?.id == competitor.id)
let panel = try #require(launcher.window as? LauncherPanel)
panel.launchNumber?(0)
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
let reloaded = LaunchHistory(defaults: defaults, now: { now })
#expect(reloaded.suggestions(in: [command, competitor]).first?.id == command.id)
}