From 6f01acfb5ff24fcf79e501d7a3c1d4fc6f4c1afd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:18:39 -0800 Subject: [PATCH 01/15] Speed up command palette search --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 293 ++++++++++++++---- .../CommandPaletteSearchEngineTests.swift | 265 ++++++++++++++++ 3 files changed, 499 insertions(+), 63 deletions(-) create mode 100644 cmuxTests/CommandPaletteSearchEngineTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9b977a9b..03578fe1 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; /* End PBXBuildFile section */ @@ -228,6 +229,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; + A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; /* End PBXFileReference section */ @@ -453,6 +455,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, + A5008382 /* CommandPaletteSearchEngineTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -687,6 +690,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, + A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a289f0eb..d240da98 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1261,6 +1261,10 @@ struct ContentView: View { @State private var commandPaletteScrollTargetIndex: Int? @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] + @State private var cachedCommandPaletteScope: CommandPaletteListScope? + @State private var cachedCommandPaletteFingerprint: Int? @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @@ -2729,7 +2733,7 @@ struct ContentView: View { } private var commandPaletteCommandListView: some View { - let visibleResults = Array(commandPaletteResults) + let visibleResults = cachedCommandPaletteResults let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 @@ -2868,7 +2872,8 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) + refreshCommandPaletteResults(forceSearchCorpusRefresh: true) + updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { _ in @@ -2876,12 +2881,17 @@ struct ContentView: View { commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + refreshCommandPaletteResults() syncCommandPaletteDebugStateForObservedWindow() } - .onChange(of: visibleResults.count) { _ in - commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) - if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { + .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in + refreshCommandPaletteResults(forceSearchCorpusRefresh: true) + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: cachedCommandPaletteResults.count) { _ in + commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count) + updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= cachedCommandPaletteResults.count { commandPaletteHoveredResultIndex = nil } syncCommandPaletteDebugStateForObservedWindow() @@ -2999,6 +3009,10 @@ struct ContentView: View { return .switcher } + private var commandPaletteCurrentSearchFingerprint: Int { + commandPaletteEntriesFingerprint(for: commandPaletteListScope) + } + private var commandPaletteSearchPlaceholder: String { switch commandPaletteListScope { case .commands: @@ -3027,8 +3041,8 @@ struct ContentView: View { } } - private var commandPaletteEntries: [CommandPaletteCommand] { - switch commandPaletteListScope { + private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { + switch scope { case .commands: return commandPaletteCommands() case .switcher: @@ -3036,39 +3050,98 @@ struct ContentView: View { } } - private var commandPaletteResults: [CommandPaletteSearchResult] { - let entries = commandPaletteEntries - let query = commandPaletteQueryForMatching - let queryIsEmpty = query.isEmpty + private func refreshCommandPaletteSearchCorpus(force: Bool = false) { + let scope = commandPaletteListScope + let fingerprint = commandPaletteEntriesFingerprint(for: scope) + guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else { + return + } - let results: [CommandPaletteSearchResult] = queryIsEmpty - ? entries.map { entry in - CommandPaletteSearchResult( - command: entry, - score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true), - titleMatchIndices: [] - ) - } - : entries.compactMap { entry in - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else { - return nil - } - return CommandPaletteSearchResult( - command: entry, - score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - query: query, - candidate: entry.title - ) - ) - } + let entries = commandPaletteEntries(for: scope) + commandPaletteSearchCorpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + cachedCommandPaletteScope = scope + cachedCommandPaletteFingerprint = fingerprint + } - return results - .sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank } - return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending + private func refreshCommandPaletteResults(forceSearchCorpusRefresh: Bool = false) { + refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) + cachedCommandPaletteResults = CommandPaletteSearchEngine.search( + entries: commandPaletteSearchCorpus, + query: commandPaletteQueryForMatching + ) { command, queryIsEmpty in + commandPaletteHistoryBoost(for: command.id, queryIsEmpty: queryIsEmpty) + } + .map { result in + CommandPaletteSearchResult( + command: result.payload, + score: result.score, + titleMatchIndices: result.titleMatchIndices + ) + } + } + + private func commandPaletteEntriesFingerprint(for scope: CommandPaletteListScope) -> Int { + switch scope { + case .commands: + return commandPaletteCommandsFingerprint() + case .switcher: + return commandPaletteSwitcherEntriesFingerprint() + } + } + + private func commandPaletteCommandsFingerprint() -> Int { + let panelContext = focusedPanelContext + let focusedDirectory: String? = { + guard let panelContext else { return nil } + if let directory = panelContext.workspace.panelDirectories[panelContext.panelId] { + return directory } + return panelContext.workspace.currentDirectory + }() + var hasher = Hasher() + hasher.combine(tabManager.sessionAutosaveFingerprint()) + hasher.combine(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) + hasher.combine(panelContext?.panelId) + hasher.combine(panelContext?.panel.panelType.rawValue) + hasher.combine(panelContext?.workspace.id) + hasher.combine(panelContext?.workspace.manualUnreadPanelIds.count ?? 0) + hasher.combine(panelContext?.workspace.sidebarPullRequestsInDisplayOrder().count ?? 0) + hasher.combine(focusedDirectory) + hasher.combine(caseIsUpdateAvailable(updateViewModel.effectiveState)) + let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + .map(\.rawValue) + .sorted() + for target in availableTargets { + hasher.combine(target) + } + return hasher.finalize() + } + + private func commandPaletteSwitcherEntriesFingerprint() -> Int { + let windowContexts = commandPaletteSwitcherWindowContexts() + var hasher = Hasher() + hasher.combine(windowContexts.count) + for context in windowContexts { + hasher.combine(context.windowId) + hasher.combine(context.windowLabel) + hasher.combine(context.selectedWorkspaceId) + hasher.combine(context.tabManager.sessionAutosaveFingerprint()) + } + return hasher.finalize() + } + + private func caseIsUpdateAvailable(_ state: UpdateState) -> Bool { + if case .updateAvailable = state { + return true + } + return false } private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { @@ -4426,7 +4499,7 @@ struct ContentView: View { } private func moveCommandPaletteSelection(by delta: Int) { - let count = commandPaletteResults.count + let count = cachedCommandPaletteResults.count guard count > 0 else { NSSound.beep() return @@ -4490,7 +4563,7 @@ struct ContentView: View { } private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { - let visibleResults = visibleResults ?? Array(commandPaletteResults) + let visibleResults = visibleResults ?? cachedCommandPaletteResults guard !visibleResults.isEmpty else { NSSound.beep() return @@ -4589,7 +4662,7 @@ struct ContentView: View { private func syncCommandPaletteDebugStateForObservedWindow() { guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) - let visibleResultCount = commandPaletteResults.count + let visibleResultCount = cachedCommandPaletteResults.count let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) @@ -4608,7 +4681,7 @@ struct ContentView: View { mode = "rename_confirm" } - let rows = Array(commandPaletteResults.prefix(20)).map { result in + let rows = Array(cachedCommandPaletteResults.prefix(20)).map { result in CommandPaletteDebugResultRow( commandId: result.command.id, title: result.command.title, @@ -4653,6 +4726,7 @@ struct ContentView: View { commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil + refreshCommandPaletteResults(forceSearchCorpusRefresh: true) resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -4670,6 +4744,10 @@ struct ContentView: View { isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil + commandPaletteSearchCorpus = [] + cachedCommandPaletteResults = [] + cachedCommandPaletteScope = nil + cachedCommandPaletteFingerprint = nil if let window = observedWindow { _ = window.makeFirstResponder(nil) } @@ -5166,23 +5244,49 @@ enum CommandPaletteSwitcherSearchIndexer { enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + static func score(query: String, candidate: String) -> Int? { score(query: query, candidates: [candidate]) } static func score(query: String, candidates: [String]) -> Int? { - let normalizedQuery = normalize(query) - guard !normalizedQuery.isEmpty else { return 0 } - let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - guard !tokens.isEmpty else { return 0 } + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } - let normalizedCandidates = candidates - .map(normalize) - .filter { !$0.isEmpty } + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } guard !normalizedCandidates.isEmpty else { return nil } var totalScore = 0 - for token in tokens { + for token in preparedQuery.tokens { var bestTokenScore: Int? for candidate in normalizedCandidates { guard let candidateScore = scoreToken(token, in: candidate) else { continue } @@ -5195,19 +5299,19 @@ enum CommandPaletteFuzzyMatcher { } static func matchCharacterIndices(query: String, candidate: String) -> Set { - let normalizedQuery = normalize(query) - guard !normalizedQuery.isEmpty else { return [] } + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } - let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - guard !tokens.isEmpty else { return [] } + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } - let loweredCandidate = normalize(candidate) + let loweredCandidate = normalizeForSearch(candidate) guard !loweredCandidate.isEmpty else { return [] } let candidateChars = Array(loweredCandidate) var matched: Set = [] - for token in tokens { + for token in preparedQuery.tokens { if token == loweredCandidate { matched.formUnion(0.. String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - private static func scoreToken(_ token: String, in candidate: String) -> Int? { guard !token.isEmpty else { return 0 } @@ -5613,6 +5710,76 @@ enum CommandPaletteFuzzyMatcher { } } +struct CommandPaletteSearchCorpusEntry { + let payload: Payload + let rank: Int + let title: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set +} + +enum CommandPaletteSearchEngine { + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + + let results: [CommandPaletteSearchCorpusResult] = queryIsEmpty + ? entries.map { entry in + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + return nil + } + return CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } +} + private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift new file mode 100644 index 00000000..337d27b2 --- /dev/null +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -0,0 +1,265 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CommandPaletteSearchEngineTests: XCTestCase { + private struct FixtureEntry { + let id: String + let rank: Int + let title: String + let searchableTexts: [String] + } + + private struct FixtureResult: Equatable { + let id: String + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set + } + + private func makeCommandEntries(count: Int) -> [FixtureEntry] { + (0.. [FixtureEntry] { + (0.. [FixtureResult] { + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + + return CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + .map { + FixtureResult( + id: $0.payload, + rank: $0.rank, + title: $0.title, + score: $0.score, + titleMatchIndices: $0.titleMatchIndices + ) + } + } + + private func legacyResults( + entries: [FixtureEntry], + query: String + ) -> [FixtureResult] { + let queryIsEmpty = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let results: [FixtureResult] = queryIsEmpty + ? entries.map { entry in + FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: []) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + query: query, + candidates: entry.searchableTexts + ) else { + return nil + } + return FixtureResult( + id: entry.id, + rank: entry.rank, + title: entry.title, + score: fuzzyScore, + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private func benchmarkElapsedMs(operation: () -> Void) -> Double { + let start = DispatchTime.now().uptimeNanoseconds + operation() + let elapsed = DispatchTime.now().uptimeNanoseconds - start + return Double(elapsed) / 1_000_000 + } + + private func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] { + Array(repeating: baseQueries, count: repetitions).flatMap { $0 } + } + + func testOptimizedSearchMatchesLegacyPipeline() { + let commandEntries = makeCommandEntries(count: 96) + let switcherEntries = makeSwitcherEntries(count: 64) + let queries = [ + "rename", + "rename tab", + "workspace", + "feature-12", + "3004", + "toggle side", + "open dir", + "phoenix", + "apply update", + ] + + for query in queries { + XCTAssertEqual( + optimizedResults(entries: commandEntries, query: query), + legacyResults(entries: commandEntries, query: query), + "Command corpus mismatch for query \(query)" + ) + XCTAssertEqual( + optimizedResults(entries: switcherEntries, query: query), + legacyResults(entries: switcherEntries, query: query), + "Switcher corpus mismatch for query \(query)" + ) + } + } + + func testCommandSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeCommandEntries(count: 900) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["rename", "rename tab", "open dir", "toggle side", "apply update", "notif", "split right", "cmux"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = legacyResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let legacyMs = benchmarkElapsedMs { + for query in queries { + _ = legacyResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + XCTAssertLessThan(optimizedMs, legacyMs) + } + + func testSwitcherSearchBenchmarkBeatsLegacyPipeline() { + let entries = makeSwitcherEntries(count: 400) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let queries = repeatedQueries( + ["workspace 12", "phoenix", "feature-18", "rename-tab", "3007", "9202", "switch", "worktrees"], + repetitions: 12 + ) + + for query in queries.prefix(8) { + _ = legacyResults(entries: entries, query: query) + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + + let legacyMs = benchmarkElapsedMs { + for query in queries { + _ = legacyResults(entries: entries, query: query) + } + } + let optimizedMs = benchmarkElapsedMs { + for query in queries { + _ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 } + } + } + + print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) + XCTAssertLessThan(optimizedMs, legacyMs) + } +} From 8a05c7d1da7a02a44f34b2fd572b38750e3480eb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:08:09 -0800 Subject: [PATCH 02/15] Decouple command palette search from typing --- Sources/ContentView.swift | 258 ++++++++++++++---- .../CommandPaletteSearchEngineTests.swift | 26 ++ 2 files changed, 236 insertions(+), 48 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d240da98..56713bff 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1261,10 +1261,17 @@ struct ContentView: View { @State private var commandPaletteScrollTargetIndex: Int? @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] @State private var cachedCommandPaletteScope: CommandPaletteListScope? @State private var cachedCommandPaletteFingerprint: Int? + @State private var commandPaletteSearchTask: Task? + @State private var commandPaletteSearchRequestID: UInt64 = 0 + @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @State private var isCommandPaletteSearchPending = false + @State private var commandPaletteDeferredSubmitRequestID: UInt64? + @State private var commandPaletteResultsRevision: UInt64 = 0 @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @@ -1377,7 +1384,7 @@ struct ContentView: View { } } - private struct CommandPaletteUsageEntry: Codable { + private struct CommandPaletteUsageEntry: Codable, Sendable { var useCount: Int var lastUsedAt: TimeInterval } @@ -2734,6 +2741,7 @@ struct ContentView: View { private var commandPaletteCommandListView: some View { let visibleResults = cachedCommandPaletteResults + let isSearchPending = isCommandPaletteSearchPending let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 @@ -2750,7 +2758,7 @@ struct ContentView: View { .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused($isCommandPaletteSearchFocused) .onSubmit { - runSelectedCommandPaletteResult(visibleResults: visibleResults) + runSelectedCommandPaletteResult() } .backport.onKeyPress(.downArrow) { _ in moveCommandPaletteSelection(by: 1) @@ -2773,6 +2781,10 @@ struct ContentView: View { handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) } + if isSearchPending { + ProgressView() + .controlSize(.small) + } } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -2782,12 +2794,22 @@ struct ContentView: View { ScrollView { LazyVStack(spacing: 0) { if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) + if isSearchPending { + HStack { + Spacer() + ProgressView() + .controlSize(.small) + Spacer() + } .padding(.vertical, 12) + } else { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } } else { ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in let isSelected = index == selectedIndex @@ -2797,7 +2819,7 @@ struct ContentView: View { : (isHovered ? Color.primary.opacity(0.08) : .clear) Button { - runCommandPaletteCommand(result.command) + runCommandPaletteResult(commandID: result.id) } label: { HStack(spacing: 8) { commandPaletteHighlightedTitleText( @@ -2832,6 +2854,7 @@ struct ContentView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(isSearchPending) .id(index) .onHover { hovering in if hovering { @@ -2872,7 +2895,6 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - refreshCommandPaletteResults(forceSearchCorpusRefresh: true) updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) resetCommandPaletteSearchFocus() } @@ -2881,14 +2903,14 @@ struct ContentView: View { commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil - refreshCommandPaletteResults() + scheduleCommandPaletteResultsRefresh() syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in - refreshCommandPaletteResults(forceSearchCorpusRefresh: true) + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) syncCommandPaletteDebugStateForObservedWindow() } - .onChange(of: cachedCommandPaletteResults.count) { _ in + .onChange(of: commandPaletteResultsRevision) { _ in commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count) updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= cachedCommandPaletteResults.count { @@ -3058,9 +3080,10 @@ struct ContentView: View { } let entries = commandPaletteEntries(for: scope) + commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) commandPaletteSearchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( - payload: entry, + payload: entry.id, rank: entry.rank, title: entry.title, searchableTexts: entry.searchableTexts @@ -3070,20 +3093,75 @@ struct ContentView: View { cachedCommandPaletteFingerprint = fingerprint } - private func refreshCommandPaletteResults(forceSearchCorpusRefresh: Bool = false) { + private func cancelCommandPaletteSearch() { + commandPaletteSearchTask?.cancel() + commandPaletteSearchTask = nil + } + + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) - cachedCommandPaletteResults = CommandPaletteSearchEngine.search( - entries: commandPaletteSearchCorpus, - query: commandPaletteQueryForMatching - ) { command, queryIsEmpty in - commandPaletteHistoryBoost(for: command.id, queryIsEmpty: queryIsEmpty) - } - .map { result in - CommandPaletteSearchResult( - command: result.payload, - score: result.score, - titleMatchIndices: result.titleMatchIndices + + commandPaletteSearchRequestID &+= 1 + let requestID = commandPaletteSearchRequestID + commandPaletteDeferredSubmitRequestID = nil + isCommandPaletteSearchPending = true + let query = commandPaletteQueryForMatching + let scope = commandPaletteListScope + let fingerprint = cachedCommandPaletteFingerprint + let searchCorpus = commandPaletteSearchCorpus + let usageHistory = commandPaletteUsageHistoryByCommandId + let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty + let historyTimestamp = Date().timeIntervalSince1970 + + cancelCommandPaletteSearch() + commandPaletteSearchTask = Task.detached(priority: .userInitiated) { + let results = CommandPaletteSearchEngine.search( + entries: searchCorpus, + query: query, + historyBoost: { commandId, _ in + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: usageHistory, + now: historyTimestamp + ) + }, + shouldCancel: { Task.isCancelled } ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard commandPaletteSearchRequestID == requestID, + isCommandPalettePresented, + commandPaletteListScope == scope, + commandPaletteQueryForMatching == query, + cachedCommandPaletteFingerprint == fingerprint else { + return + } + + cachedCommandPaletteResults = results.compactMap { result in + guard let command = commandPaletteSearchCommandsByID[result.payload] else { return nil } + return CommandPaletteSearchResult( + command: command, + score: result.score, + titleMatchIndices: result.titleMatchIndices + ) + } + commandPaletteResolvedSearchRequestID = requestID + isCommandPaletteSearchPending = false + let shouldRunDeferredSubmit = commandPaletteDeferredSubmitRequestID == requestID + if shouldRunDeferredSubmit { + commandPaletteDeferredSubmitRequestID = nil + } + commandPaletteResultsRevision &+= 1 + if commandPaletteSearchRequestID == requestID { + commandPaletteSearchTask = nil + } + if shouldRunDeferredSubmit { + runSelectedCommandPaletteResult() + } + } } } @@ -4562,8 +4640,27 @@ struct ContentView: View { return .handled } - private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { - let visibleResults = visibleResults ?? cachedCommandPaletteResults + private var commandPaletteHasCurrentResolvedResults: Bool { + !isCommandPaletteSearchPending && commandPaletteResolvedSearchRequestID == commandPaletteSearchRequestID + } + + private func runCommandPaletteResult(commandID: String) { + guard commandPaletteHasCurrentResolvedResults, + let command = cachedCommandPaletteResults.first(where: { $0.id == commandID })?.command else { + return + } + runCommandPaletteCommand(command) + } + + private func runSelectedCommandPaletteResult() { + guard commandPaletteHasCurrentResolvedResults else { + if isCommandPalettePresented { + commandPaletteDeferredSubmitRequestID = commandPaletteSearchRequestID + } + return + } + + let visibleResults = cachedCommandPaletteResults guard !visibleResults.isEmpty else { NSSound.beep() return @@ -4726,13 +4823,15 @@ struct ContentView: View { commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil - refreshCommandPaletteResults(forceSearchCorpusRefresh: true) + scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } private func dismissCommandPalette(restoreFocus: Bool = true) { let focusTarget = commandPaletteRestoreFocusTarget + cancelCommandPaletteSearch() + commandPaletteSearchRequestID &+= 1 isCommandPalettePresented = false commandPaletteMode = .commands commandPaletteQuery = "" @@ -4745,9 +4844,14 @@ struct ContentView: View { isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil commandPaletteSearchCorpus = [] + commandPaletteSearchCommandsByID = [:] cachedCommandPaletteResults = [] cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil + commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID + isCommandPaletteSearchPending = false + commandPaletteDeferredSubmitRequestID = nil + commandPaletteResultsRevision &+= 1 if let window = observedWindow { _ = window.makeFirstResponder(nil) } @@ -4910,10 +5014,14 @@ struct ContentView: View { persistCommandPaletteUsageHistory(history) } - private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { - guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 } + nonisolated private static func commandPaletteHistoryBoost( + for commandId: String, + queryIsEmpty: Bool, + history: [String: CommandPaletteUsageEntry], + now: TimeInterval + ) -> Int { + guard let entry = history[commandId] else { return 0 } - let now = Date().timeIntervalSince1970 let ageDays = max(0, now - entry.lastUsedAt) / 86_400 let recencyBoost = max(0, 320 - Int(ageDays * 20)) let countBoost = min(180, entry.useCount * 12) @@ -4922,6 +5030,15 @@ struct ContentView: View { return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) } + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: commandPaletteUsageHistoryByCommandId, + now: Date().timeIntervalSince1970 + ) + } + private func beginRenameWorkspaceFlow() { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() @@ -5710,7 +5827,7 @@ enum CommandPaletteFuzzyMatcher { } } -struct CommandPaletteSearchCorpusEntry { +struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { let payload: Payload let rank: Int let title: String @@ -5726,7 +5843,7 @@ struct CommandPaletteSearchCorpusEntry { } } -struct CommandPaletteSearchCorpusResult { +struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { let payload: Payload let rank: Int let title: String @@ -5735,42 +5852,87 @@ struct CommandPaletteSearchCorpusResult { } enum CommandPaletteSearchEngine { - static func search( + static func search( entries: [CommandPaletteSearchCorpusEntry], query: String, historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? ) -> [CommandPaletteSearchCorpusResult] { let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) - let results: [CommandPaletteSearchCorpusResult] = queryIsEmpty - ? entries.map { entry in - CommandPaletteSearchCorpusResult( + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( payload: entry.payload, rank: entry.rank, title: entry.title, score: historyBoost(entry.payload, true), titleMatchIndices: [] ) + ) } - : entries.compactMap { entry in + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( preparedQuery: preparedQuery, normalizedCandidates: entry.normalizedSearchableTexts ) else { - return nil + continue } - return CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: fuzzyScore + historyBoost(entry.payload, false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - candidate: entry.title + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) ) ) } + } + + if shouldCancel?() == true { return [] } return results.sorted { lhs, rhs in if lhs.score != rhs.score { return lhs.score > rhs.score } diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 337d27b2..182f536d 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -193,6 +193,32 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } + func testSearchCancellationReturnsNoResults() { + let entries = makeCommandEntries(count: 512) + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + var cancellationChecks = 0 + + let results = CommandPaletteSearchEngine.search( + entries: corpus, + query: "rename" + ) { _, _ in + 0 + } shouldCancel: { + cancellationChecks += 1 + return cancellationChecks >= 4 + } + + XCTAssertTrue(results.isEmpty) + XCTAssertGreaterThanOrEqual(cancellationChecks, 4) + } + func testCommandSearchBenchmarkBeatsLegacyPipeline() { let entries = makeCommandEntries(count: 900) let corpus = entries.map { entry in From 661cb99da638134dd9ea34eaf14221ae3fc3f4db Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:01:18 -0800 Subject: [PATCH 03/15] Tighten command palette search cache keys --- Sources/ContentView.swift | 404 +++++++++++++----- .../CommandPaletteSearchEngineTests.swift | 183 +++++++- 2 files changed, 478 insertions(+), 109 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 56713bff..00e57e88 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1257,6 +1257,7 @@ struct ContentView: View { @State private var commandPaletteMode: CommandPaletteMode = .commands @State private var commandPaletteRenameDraft: String = "" @State private var commandPaletteSelectedResultIndex: Int = 0 + @State private var commandPaletteSelectionAnchorCommandID: String? @State private var commandPaletteHoveredResultIndex: Int? @State private var commandPaletteScrollTargetIndex: Int? @State private var commandPaletteScrollTargetAnchor: UnitPoint? @@ -1270,7 +1271,7 @@ struct ContentView: View { @State private var commandPaletteSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 @State private var isCommandPaletteSearchPending = false - @State private var commandPaletteDeferredSubmitRequestID: UInt64? + @State private var commandPalettePendingActivation: CommandPalettePendingActivation? @State private var commandPaletteResultsRevision: UInt64 = 0 @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @@ -1291,6 +1292,16 @@ struct ContentView: View { case switcher } + enum CommandPalettePendingActivation: Equatable { + case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) + case command(requestID: UInt64, commandID: String) + } + + enum CommandPaletteResolvedActivation: Equatable { + case selected(index: Int) + case command(commandID: String) + } + private struct CommandPaletteRenameTarget: Equatable { enum Kind: Equatable { case workspace(workspaceId: UUID) @@ -1412,6 +1423,13 @@ struct ContentView: View { func string(_ key: String) -> String? { stringValues[key] } + + func fingerprint() -> Int { + ContentView.commandPaletteContextFingerprint( + boolValues: boolValues, + stringValues: stringValues + ) + } } private enum CommandPaletteContextKeys { @@ -1495,6 +1513,19 @@ struct ContentView: View { let windowLabel: String? } + struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { + let id: UUID + let displayName: String + let metadata: CommandPaletteSwitcherSearchMetadata + } + + struct CommandPaletteSwitcherFingerprintContext: Sendable { + let windowId: UUID + let windowLabel: String? + let selectedWorkspaceId: UUID? + let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] + } + private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot @@ -2741,7 +2772,6 @@ struct ContentView: View { private var commandPaletteCommandListView: some View { let visibleResults = cachedCommandPaletteResults - let isSearchPending = isCommandPaletteSearchPending let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 @@ -2780,11 +2810,6 @@ struct ContentView: View { .backport.onKeyPress("k") { modifiers in handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) } - - if isSearchPending { - ProgressView() - .controlSize(.small) - } } .padding(.horizontal, 9) .padding(.vertical, 7) @@ -2794,22 +2819,12 @@ struct ContentView: View { ScrollView { LazyVStack(spacing: 0) { if visibleResults.isEmpty { - if isSearchPending { - HStack { - Spacer() - ProgressView() - .controlSize(.small) - Spacer() - } + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) .padding(.vertical, 12) - } else { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) - } } else { ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in let isSelected = index == selectedIndex @@ -2854,7 +2869,6 @@ struct ContentView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(isSearchPending) .id(index) .onHover { hovering in if hovering { @@ -2900,6 +2914,7 @@ struct ContentView: View { } .onChange(of: commandPaletteQuery) { _ in commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil @@ -2911,7 +2926,13 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: commandPaletteResultsRevision) { _ in - commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count) + let resultIDs = cachedCommandPaletteResults.map(\.id) + commandPaletteSelectedResultIndex = Self.commandPaletteResolvedSelectionIndex( + preferredCommandID: commandPaletteSelectionAnchorCommandID, + fallbackSelectedIndex: commandPaletteSelectedResultIndex, + resultIDs: resultIDs + ) + syncCommandPaletteSelectionAnchorFromCurrentResults() updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= cachedCommandPaletteResults.count { commandPaletteHoveredResultIndex = nil @@ -3098,34 +3119,76 @@ struct ContentView: View { commandPaletteSearchTask = nil } + nonisolated private static func commandPaletteResolvedSearchResults( + searchCorpus: [CommandPaletteSearchCorpusEntry], + commandsByID: [String: CommandPaletteCommand], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + shouldCancel: @escaping () -> Bool = { false } + ) -> [CommandPaletteSearchResult] { + let results = CommandPaletteSearchEngine.search( + entries: searchCorpus, + query: query, + historyBoost: { commandId, _ in + Self.commandPaletteHistoryBoost( + for: commandId, + queryIsEmpty: queryIsEmpty, + history: usageHistory, + now: historyTimestamp + ) + }, + shouldCancel: shouldCancel + ) + + return results.compactMap { result in + guard let command = commandsByID[result.payload] else { return nil } + return CommandPaletteSearchResult( + command: command, + score: result.score, + titleMatchIndices: result.titleMatchIndices + ) + } + } + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) commandPaletteSearchRequestID &+= 1 let requestID = commandPaletteSearchRequestID - commandPaletteDeferredSubmitRequestID = nil - isCommandPaletteSearchPending = true let query = commandPaletteQueryForMatching let scope = commandPaletteListScope let fingerprint = cachedCommandPaletteFingerprint let searchCorpus = commandPaletteSearchCorpus + let commandsByID = commandPaletteSearchCommandsByID let usageHistory = commandPaletteUsageHistoryByCommandId let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty let historyTimestamp = Date().timeIntervalSince1970 + commandPalettePendingActivation = nil + if cachedCommandPaletteResults.isEmpty { + cachedCommandPaletteResults = Self.commandPaletteResolvedSearchResults( + searchCorpus: searchCorpus, + commandsByID: commandsByID, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + commandPaletteResolvedSearchRequestID = requestID + commandPaletteResultsRevision &+= 1 + } + isCommandPaletteSearchPending = true cancelCommandPaletteSearch() commandPaletteSearchTask = Task.detached(priority: .userInitiated) { - let results = CommandPaletteSearchEngine.search( - entries: searchCorpus, + let results = Self.commandPaletteResolvedSearchResults( + searchCorpus: searchCorpus, + commandsByID: commandsByID, query: query, - historyBoost: { commandId, _ in - Self.commandPaletteHistoryBoost( - for: commandId, - queryIsEmpty: queryIsEmpty, - history: usageHistory, - now: historyTimestamp - ) - }, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, shouldCancel: { Task.isCancelled } ) @@ -3140,26 +3203,25 @@ struct ContentView: View { return } - cachedCommandPaletteResults = results.compactMap { result in - guard let command = commandPaletteSearchCommandsByID[result.payload] else { return nil } - return CommandPaletteSearchResult( - command: command, - score: result.score, - titleMatchIndices: result.titleMatchIndices - ) - } + cachedCommandPaletteResults = results + let resultIDs = cachedCommandPaletteResults.map(\.id) + let pendingActivation = commandPalettePendingActivation + let resolvedActivation = Self.commandPaletteResolvedPendingActivation( + pendingActivation, + requestID: requestID, + resultIDs: resultIDs + ) commandPaletteResolvedSearchRequestID = requestID isCommandPaletteSearchPending = false - let shouldRunDeferredSubmit = commandPaletteDeferredSubmitRequestID == requestID - if shouldRunDeferredSubmit { - commandPaletteDeferredSubmitRequestID = nil + if Self.commandPalettePendingActivationRequestID(pendingActivation) == requestID { + commandPalettePendingActivation = nil } commandPaletteResultsRevision &+= 1 if commandPaletteSearchRequestID == requestID { commandPaletteSearchTask = nil } - if shouldRunDeferredSubmit { - runSelectedCommandPaletteResult() + if let resolvedActivation { + runCommandPaletteResolvedActivation(resolvedActivation) } } } @@ -3175,51 +3237,29 @@ struct ContentView: View { } private func commandPaletteCommandsFingerprint() -> Int { - let panelContext = focusedPanelContext - let focusedDirectory: String? = { - guard let panelContext else { return nil } - if let directory = panelContext.workspace.panelDirectories[panelContext.panelId] { - return directory - } - return panelContext.workspace.currentDirectory - }() var hasher = Hasher() - hasher.combine(tabManager.sessionAutosaveFingerprint()) + hasher.combine(commandPaletteContextSnapshot().fingerprint()) hasher.combine(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) - hasher.combine(panelContext?.panelId) - hasher.combine(panelContext?.panel.panelType.rawValue) - hasher.combine(panelContext?.workspace.id) - hasher.combine(panelContext?.workspace.manualUnreadPanelIds.count ?? 0) - hasher.combine(panelContext?.workspace.sidebarPullRequestsInDisplayOrder().count ?? 0) - hasher.combine(focusedDirectory) - hasher.combine(caseIsUpdateAvailable(updateViewModel.effectiveState)) - let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets - .map(\.rawValue) - .sorted() - for target in availableTargets { - hasher.combine(target) - } return hasher.finalize() } private func commandPaletteSwitcherEntriesFingerprint() -> Int { let windowContexts = commandPaletteSwitcherWindowContexts() - var hasher = Hasher() - hasher.combine(windowContexts.count) - for context in windowContexts { - hasher.combine(context.windowId) - hasher.combine(context.windowLabel) - hasher.combine(context.selectedWorkspaceId) - hasher.combine(context.tabManager.sessionAutosaveFingerprint()) + let fingerprintContexts = windowContexts.map { context in + CommandPaletteSwitcherFingerprintContext( + windowId: context.windowId, + windowLabel: context.windowLabel, + selectedWorkspaceId: context.selectedWorkspaceId, + workspaces: commandPaletteOrderedSwitcherWorkspaces(for: context).map { workspace in + CommandPaletteSwitcherFingerprintWorkspace( + id: workspace.id, + displayName: workspaceDisplayName(workspace), + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace) + ) + } + ) } - return hasher.finalize() - } - - private func caseIsUpdateAvailable(_ state: UpdateState) -> Bool { - if case .updateAvailable = state { - return true - } - return false + return Self.commandPaletteSwitcherFingerprint(windowContexts: fingerprintContexts) } private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { @@ -3274,16 +3314,9 @@ struct ContentView: View { var nextRank = 0 for context in windowContexts { - var workspaces = context.tabManager.tabs + let workspaces = commandPaletteOrderedSwitcherWorkspaces(for: context) guard !workspaces.isEmpty else { continue } - let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId - if let selectedWorkspaceId, - let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { - let selectedWorkspace = workspaces.remove(at: selectedIndex) - workspaces.insert(selectedWorkspace, at: 0) - } - let windowId = context.windowId let windowTabManager = context.tabManager let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) @@ -3386,6 +3419,22 @@ struct ContentView: View { return ["window", windowLabel.lowercased()] } + private func commandPaletteOrderedSwitcherWorkspaces( + for context: CommandPaletteSwitcherWindowContext + ) -> [Workspace] { + var workspaces = context.tabManager.tabs + guard !workspaces.isEmpty else { return [] } + + let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId + if let selectedWorkspaceId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) + } + + return workspaces + } + private func focusCommandPaletteSwitcherTarget( windowId: UUID, tabManager: TabManager, @@ -4537,6 +4586,107 @@ struct ContentView: View { return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) } + static func commandPaletteResolvedSelectionIndex( + preferredCommandID: String?, + fallbackSelectedIndex: Int, + resultIDs: [String] + ) -> Int { + guard !resultIDs.isEmpty else { return 0 } + if let preferredCommandID, + let anchoredIndex = resultIDs.firstIndex(of: preferredCommandID) { + return anchoredIndex + } + return min(max(fallbackSelectedIndex, 0), resultIDs.count - 1) + } + + static func commandPalettePendingActivationRequestID( + _ pendingActivation: CommandPalettePendingActivation? + ) -> UInt64? { + switch pendingActivation { + case .selected(let requestID, _, _): + return requestID + case .command(let requestID, _): + return requestID + case nil: + return nil + } + } + + static func commandPaletteResolvedPendingActivation( + _ pendingActivation: CommandPalettePendingActivation?, + requestID: UInt64, + resultIDs: [String] + ) -> CommandPaletteResolvedActivation? { + switch pendingActivation { + case .selected(let activationRequestID, let fallbackSelectedIndex, let preferredCommandID): + guard activationRequestID == requestID else { return nil } + let resolvedIndex = commandPaletteResolvedSelectionIndex( + preferredCommandID: preferredCommandID, + fallbackSelectedIndex: fallbackSelectedIndex, + resultIDs: resultIDs + ) + return .selected(index: resolvedIndex) + case .command(let activationRequestID, let commandID): + guard activationRequestID == requestID, resultIDs.contains(commandID) else { return nil } + return .command(commandID: commandID) + case nil: + return nil + } + } + + static func commandPaletteContextFingerprint( + boolValues: [String: Bool], + stringValues: [String: String] + ) -> Int { + var hasher = Hasher() + for key in boolValues.keys.sorted() { + hasher.combine(key) + hasher.combine(boolValues[key] ?? false) + } + for key in stringValues.keys.sorted() { + hasher.combine(key) + hasher.combine(stringValues[key] ?? "") + } + return hasher.finalize() + } + + static func commandPaletteSwitcherFingerprint( + windowContexts: [CommandPaletteSwitcherFingerprintContext] + ) -> Int { + var hasher = Hasher() + hasher.combine(windowContexts.count) + for context in windowContexts { + hasher.combine(context.windowId) + hasher.combine(context.windowLabel) + hasher.combine(context.selectedWorkspaceId) + hasher.combine(context.workspaces.count) + for workspace in context.workspaces { + hasher.combine(workspace.id) + hasher.combine(workspace.displayName) + combineCommandPaletteSwitcherSearchMetadata(workspace.metadata, into: &hasher) + } + } + return hasher.finalize() + } + + static func combineCommandPaletteSwitcherSearchMetadata( + _ metadata: CommandPaletteSwitcherSearchMetadata, + into hasher: inout Hasher + ) { + hasher.combine(metadata.directories.count) + for directory in metadata.directories { + hasher.combine(directory) + } + hasher.combine(metadata.branches.count) + for branch in metadata.branches { + hasher.combine(branch) + } + hasher.combine(metadata.ports.count) + for port in metadata.ports { + hasher.combine(port) + } + } + static func commandPaletteScrollPositionAnchor( selectedIndex: Int, resultCount: Int @@ -4576,6 +4726,15 @@ struct ContentView: View { } } + private func syncCommandPaletteSelectionAnchorFromCurrentResults() { + guard !cachedCommandPaletteResults.isEmpty else { + commandPaletteSelectionAnchorCommandID = nil + return + } + let selectedIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count) + commandPaletteSelectionAnchorCommandID = cachedCommandPaletteResults[selectedIndex].id + } + private func moveCommandPaletteSelection(by delta: Int) { let count = cachedCommandPaletteResults.count guard count > 0 else { @@ -4584,6 +4743,9 @@ struct ContentView: View { } let current = commandPaletteSelectedIndex(resultCount: count) commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) + if commandPaletteHasCurrentResolvedResults { + syncCommandPaletteSelectionAnchorFromCurrentResults() + } syncCommandPaletteDebugStateForObservedWindow() } @@ -4644,29 +4806,55 @@ struct ContentView: View { !isCommandPaletteSearchPending && commandPaletteResolvedSearchRequestID == commandPaletteSearchRequestID } + private func runCommandPaletteResolvedActivation(_ activation: CommandPaletteResolvedActivation) { + switch activation { + case .command(let commandID): + guard let command = cachedCommandPaletteResults.first(where: { $0.id == commandID })?.command else { + return + } + runCommandPaletteCommand(command) + case .selected(let fallbackIndex): + guard !cachedCommandPaletteResults.isEmpty else { + NSSound.beep() + return + } + let resolvedIndex = Self.commandPaletteResolvedSelectionIndex( + preferredCommandID: commandPaletteSelectionAnchorCommandID, + fallbackSelectedIndex: fallbackIndex, + resultIDs: cachedCommandPaletteResults.map(\.id) + ) + commandPaletteSelectedResultIndex = resolvedIndex + syncCommandPaletteSelectionAnchorFromCurrentResults() + runCommandPaletteCommand(cachedCommandPaletteResults[resolvedIndex].command) + } + } + private func runCommandPaletteResult(commandID: String) { - guard commandPaletteHasCurrentResolvedResults, - let command = cachedCommandPaletteResults.first(where: { $0.id == commandID })?.command else { + guard commandPaletteHasCurrentResolvedResults else { + if isCommandPalettePresented { + commandPalettePendingActivation = .command( + requestID: commandPaletteSearchRequestID, + commandID: commandID + ) + } return } - runCommandPaletteCommand(command) + runCommandPaletteResolvedActivation(.command(commandID: commandID)) } private func runSelectedCommandPaletteResult() { guard commandPaletteHasCurrentResolvedResults else { if isCommandPalettePresented { - commandPaletteDeferredSubmitRequestID = commandPaletteSearchRequestID + commandPalettePendingActivation = .selected( + requestID: commandPaletteSearchRequestID, + fallbackSelectedIndex: commandPaletteSelectedResultIndex, + preferredCommandID: commandPaletteSelectionAnchorCommandID + ) } return } - let visibleResults = cachedCommandPaletteResults - guard !visibleResults.isEmpty else { - NSSound.beep() - return - } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - runCommandPaletteCommand(visibleResults[index].command) + runCommandPaletteResolvedActivation(.selected(index: commandPaletteSelectedResultIndex)) } private func handleCommandPaletteSubmitRequest() { @@ -4820,6 +5008,7 @@ struct ContentView: View { commandPaletteQuery = initialQuery commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil @@ -4837,6 +5026,7 @@ struct ContentView: View { commandPaletteQuery = "" commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 + commandPaletteSelectionAnchorCommandID = nil commandPaletteHoveredResultIndex = nil commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil @@ -4850,7 +5040,7 @@ struct ContentView: View { cachedCommandPaletteFingerprint = nil commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID isCommandPaletteSearchPending = false - commandPaletteDeferredSubmitRequestID = nil + commandPalettePendingActivation = nil commandPaletteResultsRevision &+= 1 if let window = observedWindow { _ = window.makeFirstResponder(nil) @@ -5242,7 +5432,7 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata { +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { let directories: [String] let branches: [String] let ports: [Int] diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 182f536d..32d8ef82 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -219,6 +219,177 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertGreaterThanOrEqual(cancellationChecks, 4) } + func testResolvedSelectionIndexPrefersAnchoredCommand() { + let resultIDs = ["command.0", "command.1", "command.2"] + + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: "command.2", + fallbackSelectedIndex: 0, + resultIDs: resultIDs + ), + 2 + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: "missing", + fallbackSelectedIndex: 9, + resultIDs: resultIDs + ), + 2 + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedSelectionIndex( + preferredCommandID: nil, + fallbackSelectedIndex: 1, + resultIDs: [] + ), + 0 + ) + } + + func testResolvedPendingActivationPreservesSubmitAndClickSemantics() { + let resultIDs = ["command.0", "command.1", "command.2"] + + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .selected(requestID: 41, fallbackSelectedIndex: 0, preferredCommandID: "command.2"), + requestID: 41, + resultIDs: resultIDs + ), + .selected(index: 2) + ) + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .command(requestID: 41, commandID: "command.1"), + requestID: 41, + resultIDs: resultIDs + ), + .command(commandID: "command.1") + ) + XCTAssertNil( + ContentView.commandPaletteResolvedPendingActivation( + .command(requestID: 41, commandID: "missing"), + requestID: 41, + resultIDs: resultIDs + ) + ) + XCTAssertNil( + ContentView.commandPaletteResolvedPendingActivation( + .selected(requestID: 40, fallbackSelectedIndex: 0, preferredCommandID: nil), + requestID: 41, + resultIDs: resultIDs + ) + ) + } + + func testCommandContextFingerprintTracksExactContextValues() { + let base = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": false, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Main", + ] + ) + let unreadChanged = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": true, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Main", + ] + ) + let renamed = ContentView.commandPaletteContextFingerprint( + boolValues: [ + "workspace.hasPullRequests": true, + "panel.hasUnread": false, + "panel.isTerminal": true, + ], + stringValues: [ + "workspace.name": "Alpha", + "panel.name": "Logs", + ] + ) + + XCTAssertNotEqual(base, unreadChanged) + XCTAssertNotEqual(base, renamed) + } + + func testSwitcherFingerprintTracksMetadataValuesAtSameCardinality() { + let windowID = UUID() + let workspaceID = UUID() + let base = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm"], + branches: ["feature/search-speed"], + ports: [3000] + ) + ) + ] + ) + ] + ) + let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Alpha", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/other"], + branches: ["feature/search-speed"], + ports: [4000] + ) + ) + ] + ) + ] + ) + let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( + windowContexts: [ + ContentView.CommandPaletteSwitcherFingerprintContext( + windowId: windowID, + windowLabel: "Window 2", + selectedWorkspaceId: workspaceID, + workspaces: [ + ContentView.CommandPaletteSwitcherFingerprintWorkspace( + id: workspaceID, + displayName: "Workspace Beta", + metadata: CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm"], + branches: ["feature/search-speed"], + ports: [3000] + ) + ) + ] + ) + ] + ) + + XCTAssertNotEqual(base, changedMetadata) + XCTAssertNotEqual(base, changedDisplayName) + } + func testCommandSearchBenchmarkBeatsLegacyPipeline() { let entries = makeCommandEntries(count: 900) let corpus = entries.map { entry in @@ -251,7 +422,11 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) - XCTAssertLessThan(optimizedMs, legacyMs) + XCTAssertLessThan( + optimizedMs, + legacyMs * 1.25, + "Optimized command search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + ) } func testSwitcherSearchBenchmarkBeatsLegacyPipeline() { @@ -286,6 +461,10 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs)) - XCTAssertLessThan(optimizedMs, legacyMs) + XCTAssertLessThan( + optimizedMs, + legacyMs * 1.25, + "Optimized switcher search regressed significantly: legacy=\(legacyMs) optimized=\(optimizedMs)" + ) } } From b268b2fc8fa263dd11331b8d751abec4fba74630 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:42:37 -0800 Subject: [PATCH 04/15] Fix command palette async search review issues --- Sources/ContentView.swift | 51 +++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f17f60de..d8056d53 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1559,6 +1559,12 @@ struct ContentView: View { var id: String { command.id } } + private struct CommandPaletteResolvedSearchMatch: Sendable { + let commandID: String + let score: Int + let titleMatchIndices: Set + } + private struct CommandPaletteSwitcherWindowContext { let windowId: UUID let tabManager: TabManager @@ -3284,15 +3290,14 @@ struct ContentView: View { commandPaletteSearchTask = nil } - nonisolated private static func commandPaletteResolvedSearchResults( + nonisolated private static func commandPaletteResolvedSearchMatches( searchCorpus: [CommandPaletteSearchCorpusEntry], - commandsByID: [String: CommandPaletteCommand], query: String, usageHistory: [String: CommandPaletteUsageEntry], queryIsEmpty: Bool, historyTimestamp: TimeInterval, shouldCancel: @escaping () -> Bool = { false } - ) -> [CommandPaletteSearchResult] { + ) -> [CommandPaletteResolvedSearchMatch] { let results = CommandPaletteSearchEngine.search( entries: searchCorpus, query: query, @@ -3307,16 +3312,29 @@ struct ContentView: View { shouldCancel: shouldCancel ) - return results.compactMap { result in - guard let command = commandsByID[result.payload] else { return nil } - return CommandPaletteSearchResult( - command: command, + return results.map { result in + CommandPaletteResolvedSearchMatch( + commandID: result.payload, score: result.score, titleMatchIndices: result.titleMatchIndices ) } } + private static func commandPaletteMaterializedSearchResults( + matches: [CommandPaletteResolvedSearchMatch], + commandsByID: [String: CommandPaletteCommand] + ) -> [CommandPaletteSearchResult] { + matches.compactMap { match in + guard let command = commandsByID[match.commandID] else { return nil } + return CommandPaletteSearchResult( + command: command, + score: match.score, + titleMatchIndices: match.titleMatchIndices + ) + } + } + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) @@ -3331,25 +3349,29 @@ struct ContentView: View { let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty let historyTimestamp = Date().timeIntervalSince1970 commandPalettePendingActivation = nil + cancelCommandPaletteSearch() if cachedCommandPaletteResults.isEmpty { - cachedCommandPaletteResults = Self.commandPaletteResolvedSearchResults( + let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, - commandsByID: commandsByID, query: query, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, historyTimestamp: historyTimestamp ) + cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( + matches: matches, + commandsByID: commandsByID + ) commandPaletteResolvedSearchRequestID = requestID + isCommandPaletteSearchPending = false commandPaletteResultsRevision &+= 1 + return } isCommandPaletteSearchPending = true - cancelCommandPaletteSearch() commandPaletteSearchTask = Task.detached(priority: .userInitiated) { - let results = Self.commandPaletteResolvedSearchResults( + let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, - commandsByID: commandsByID, query: query, usageHistory: usageHistory, queryIsEmpty: queryIsEmpty, @@ -3368,7 +3390,10 @@ struct ContentView: View { return } - cachedCommandPaletteResults = results + cachedCommandPaletteResults = Self.commandPaletteMaterializedSearchResults( + matches: matches, + commandsByID: commandPaletteSearchCommandsByID + ) let resultIDs = cachedCommandPaletteResults.map(\.id) let pendingActivation = commandPalettePendingActivation let resolvedActivation = Self.commandPaletteResolvedPendingActivation( From 5279e005f9ec671105d527e98db64007cb32fb39 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:59:41 -0800 Subject: [PATCH 05/15] Fix stale command palette preview results --- Sources/ContentView.swift | 144 ++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 14 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d8056d53..9ca93dd2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1315,6 +1315,7 @@ struct ContentView: View { @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] @State private var cachedCommandPaletteScope: CommandPaletteListScope? @@ -1322,6 +1323,9 @@ struct ContentView: View { @State private var commandPaletteSearchTask: Task? @State private var commandPaletteSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? + @State private var commandPaletteResolvedSearchFingerprint: Int? + @State private var commandPaletteSearchHistoryTimestamp: TimeInterval = 0 @State private var isCommandPaletteSearchPending = false @State private var commandPalettePendingActivation: CommandPalettePendingActivation? @State private var commandPaletteResultsRevision: UInt64 = 0 @@ -1591,6 +1595,8 @@ struct ContentView: View { ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" private static let commandPaletteCommandsPrefix = ">" + private static let commandPaletteVisiblePreviewCandidateLimit = 256 + private static let commandPaletteVisiblePreviewResultLimit = 48 private static let minimumSidebarWidth: CGFloat = 186 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 @@ -2940,7 +2946,7 @@ struct ContentView: View { } private var commandPaletteCommandListView: some View { - let visibleResults = cachedCommandPaletteResults + let visibleResults = commandPaletteVisibleResults let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 @@ -2989,12 +2995,18 @@ struct ContentView: View { ScrollView { LazyVStack(spacing: 0) { if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) + if commandPaletteHasCurrentResolvedResults { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: commandPaletteEmptyStateHeight) + } } else { ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in let isSelected = index == selectedIndex @@ -3079,7 +3091,7 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { _ in @@ -3089,10 +3101,12 @@ struct ContentView: View { commandPaletteScrollTargetIndex = nil commandPaletteScrollTargetAnchor = nil scheduleCommandPaletteResultsRefresh() + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: commandPaletteCurrentSearchFingerprint) { _ in scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true) + updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false) syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: commandPaletteResultsRevision) { _ in @@ -3103,8 +3117,9 @@ struct ContentView: View { resultIDs: resultIDs ) syncCommandPaletteSelectionAnchorFromCurrentResults() - updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false) - if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= cachedCommandPaletteResults.count { + let visibleResultCount = commandPaletteVisibleResults.count + updateCommandPaletteScrollTarget(resultCount: visibleResultCount, animated: false) + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResultCount { commandPaletteHoveredResultIndex = nil } syncCommandPaletteDebugStateForObservedWindow() @@ -3255,6 +3270,43 @@ struct ContentView: View { } } + private var commandPaletteResolvedResultsMatchCurrentSearchContext: Bool { + commandPaletteResolvedSearchScope == commandPaletteListScope && + commandPaletteResolvedSearchFingerprint == cachedCommandPaletteFingerprint + } + + private var commandPaletteVisibleResults: [CommandPaletteSearchResult] { + if commandPaletteHasCurrentResolvedResults { + return cachedCommandPaletteResults + } + + guard !commandPaletteSearchCorpus.isEmpty else { + return [] + } + + let prioritizedCommandIDs = commandPaletteResolvedResultsMatchCurrentSearchContext + ? cachedCommandPaletteResults.map(\.id) + : [] + let query = commandPaletteQueryForMatching + let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty + let previewMatches = Self.commandPalettePreviewSearchMatches( + searchCorpus: commandPaletteSearchCorpus, + searchCorpusByID: commandPaletteSearchCorpusByID, + prioritizedCommandIDs: prioritizedCommandIDs, + query: query, + usageHistory: commandPaletteUsageHistoryByCommandId, + queryIsEmpty: queryIsEmpty, + historyTimestamp: commandPaletteSearchHistoryTimestamp, + candidateLimit: Self.commandPaletteVisiblePreviewCandidateLimit, + resultLimit: Self.commandPaletteVisiblePreviewResultLimit + ) + + return Self.commandPaletteMaterializedSearchResults( + matches: previewMatches, + commandsByID: commandPaletteSearchCommandsByID + ) + } + private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { switch scope { case .commands: @@ -3273,7 +3325,7 @@ struct ContentView: View { let entries = commandPaletteEntries(for: scope) commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) - commandPaletteSearchCorpus = entries.map { entry in + let searchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( payload: entry.id, rank: entry.rank, @@ -3281,6 +3333,8 @@ struct ContentView: View { searchableTexts: entry.searchableTexts ) } + commandPaletteSearchCorpus = searchCorpus + commandPaletteSearchCorpusByID = Dictionary(uniqueKeysWithValues: searchCorpus.map { ($0.payload, $0) }) cachedCommandPaletteScope = scope cachedCommandPaletteFingerprint = fingerprint } @@ -3335,6 +3389,59 @@ struct ContentView: View { } } + nonisolated private static func commandPalettePreviewSearchMatches( + searchCorpus: [CommandPaletteSearchCorpusEntry], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + prioritizedCommandIDs: [String], + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval, + candidateLimit: Int, + resultLimit: Int + ) -> [CommandPaletteResolvedSearchMatch] { + guard !searchCorpus.isEmpty, candidateLimit > 0, resultLimit > 0 else { + return [] + } + + var previewEntries: [CommandPaletteSearchCorpusEntry] = [] + previewEntries.reserveCapacity(min(candidateLimit, searchCorpus.count)) + var seenCommandIDs: Set = [] + + for commandID in prioritizedCommandIDs { + guard seenCommandIDs.insert(commandID).inserted, + let entry = searchCorpusByID[commandID] else { + continue + } + previewEntries.append(entry) + if previewEntries.count == candidateLimit { + break + } + } + + if previewEntries.count < candidateLimit { + for entry in searchCorpus { + guard seenCommandIDs.insert(entry.payload).inserted else { continue } + previewEntries.append(entry) + if previewEntries.count == candidateLimit { + break + } + } + } + + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: previewEntries, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) + } + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) @@ -3348,6 +3455,7 @@ struct ContentView: View { let usageHistory = commandPaletteUsageHistoryByCommandId let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty let historyTimestamp = Date().timeIntervalSince1970 + commandPaletteSearchHistoryTimestamp = historyTimestamp commandPalettePendingActivation = nil cancelCommandPaletteSearch() if cachedCommandPaletteResults.isEmpty { @@ -3363,6 +3471,8 @@ struct ContentView: View { commandsByID: commandsByID ) commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + commandPaletteResolvedSearchFingerprint = fingerprint isCommandPaletteSearchPending = false commandPaletteResultsRevision &+= 1 return @@ -3402,6 +3512,8 @@ struct ContentView: View { resultIDs: resultIDs ) commandPaletteResolvedSearchRequestID = requestID + commandPaletteResolvedSearchScope = scope + commandPaletteResolvedSearchFingerprint = fingerprint isCommandPaletteSearchPending = false if Self.commandPalettePendingActivationRequestID(pendingActivation) == requestID { commandPalettePendingActivation = nil @@ -4926,7 +5038,7 @@ struct ContentView: View { } private func moveCommandPaletteSelection(by delta: Int) { - let count = cachedCommandPaletteResults.count + let count = commandPaletteVisibleResults.count guard count > 0 else { NSSound.beep() return @@ -5143,7 +5255,7 @@ struct ContentView: View { private func syncCommandPaletteDebugStateForObservedWindow() { guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) - let visibleResultCount = cachedCommandPaletteResults.count + let visibleResultCount = commandPaletteVisibleResults.count let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) @@ -5162,7 +5274,7 @@ struct ContentView: View { mode = "rename_confirm" } - let rows = Array(cachedCommandPaletteResults.prefix(20)).map { result in + let rows = Array(commandPaletteVisibleResults.prefix(20)).map { result in CommandPaletteDebugResultRow( commandId: result.command.id, title: result.command.title, @@ -5237,11 +5349,15 @@ struct ContentView: View { isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil commandPaletteSearchCorpus = [] + commandPaletteSearchCorpusByID = [:] commandPaletteSearchCommandsByID = [:] cachedCommandPaletteResults = [] cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID + commandPaletteResolvedSearchScope = nil + commandPaletteResolvedSearchFingerprint = nil + commandPaletteSearchHistoryTimestamp = 0 isCommandPaletteSearchPending = false commandPalettePendingActivation = nil commandPaletteResultsRevision &+= 1 From 23910dea9a13faa0e42152d5115664f72a891a13 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:13:30 -0800 Subject: [PATCH 06/15] Reduce command palette typing lag --- Sources/ContentView.swift | 143 ++++++++++++++++++++------------------ 1 file changed, 77 insertions(+), 66 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9ca93dd2..5db8359a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1318,6 +1318,9 @@ struct ContentView: View { @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] + @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] + @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? + @State private var commandPaletteVisibleResultsFingerprint: Int? @State private var cachedCommandPaletteScope: CommandPaletteListScope? @State private var cachedCommandPaletteFingerprint: Int? @State private var commandPaletteSearchTask: Task? @@ -1325,7 +1328,6 @@ struct ContentView: View { @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? @State private var commandPaletteResolvedSearchFingerprint: Int? - @State private var commandPaletteSearchHistoryTimestamp: TimeInterval = 0 @State private var isCommandPaletteSearchPending = false @State private var commandPalettePendingActivation: CommandPalettePendingActivation? @State private var commandPaletteResultsRevision: UInt64 = 0 @@ -1595,7 +1597,6 @@ struct ContentView: View { ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" private static let commandPaletteCommandsPrefix = ">" - private static let commandPaletteVisiblePreviewCandidateLimit = 256 private static let commandPaletteVisiblePreviewResultLimit = 48 private static let minimumSidebarWidth: CGFloat = 186 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 @@ -3270,43 +3271,6 @@ struct ContentView: View { } } - private var commandPaletteResolvedResultsMatchCurrentSearchContext: Bool { - commandPaletteResolvedSearchScope == commandPaletteListScope && - commandPaletteResolvedSearchFingerprint == cachedCommandPaletteFingerprint - } - - private var commandPaletteVisibleResults: [CommandPaletteSearchResult] { - if commandPaletteHasCurrentResolvedResults { - return cachedCommandPaletteResults - } - - guard !commandPaletteSearchCorpus.isEmpty else { - return [] - } - - let prioritizedCommandIDs = commandPaletteResolvedResultsMatchCurrentSearchContext - ? cachedCommandPaletteResults.map(\.id) - : [] - let query = commandPaletteQueryForMatching - let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty - let previewMatches = Self.commandPalettePreviewSearchMatches( - searchCorpus: commandPaletteSearchCorpus, - searchCorpusByID: commandPaletteSearchCorpusByID, - prioritizedCommandIDs: prioritizedCommandIDs, - query: query, - usageHistory: commandPaletteUsageHistoryByCommandId, - queryIsEmpty: queryIsEmpty, - historyTimestamp: commandPaletteSearchHistoryTimestamp, - candidateLimit: Self.commandPaletteVisiblePreviewCandidateLimit, - resultLimit: Self.commandPaletteVisiblePreviewResultLimit - ) - - return Self.commandPaletteMaterializedSearchResults( - matches: previewMatches, - commandsByID: commandPaletteSearchCommandsByID - ) - } - private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { switch scope { case .commands: @@ -3389,44 +3353,72 @@ struct ContentView: View { } } + private func setCommandPaletteVisibleResults( + _ results: [CommandPaletteSearchResult], + scope: CommandPaletteListScope, + fingerprint: Int? + ) { + commandPaletteVisibleResults = results + commandPaletteVisibleResultsScope = scope + commandPaletteVisibleResultsFingerprint = fingerprint + } + + private func refreshPendingCommandPaletteVisibleResults( + scope: CommandPaletteListScope, + fingerprint: Int?, + query: String, + usageHistory: [String: CommandPaletteUsageEntry], + queryIsEmpty: Bool, + historyTimestamp: TimeInterval + ) { + let candidateCommandIDs: [String] + if commandPaletteVisibleResultsScope == scope, + commandPaletteVisibleResultsFingerprint == fingerprint { + candidateCommandIDs = commandPaletteVisibleResults.map(\.id) + } else { + candidateCommandIDs = [] + } + + let previewMatches = Self.commandPalettePreviewSearchMatches( + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: commandPaletteSearchCorpusByID, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp, + resultLimit: Self.commandPaletteVisiblePreviewResultLimit + ) + let previewResults = Self.commandPaletteMaterializedSearchResults( + matches: previewMatches, + commandsByID: commandPaletteSearchCommandsByID + ) + setCommandPaletteVisibleResults( + previewResults, + scope: scope, + fingerprint: fingerprint + ) + } + nonisolated private static func commandPalettePreviewSearchMatches( - searchCorpus: [CommandPaletteSearchCorpusEntry], + candidateCommandIDs: [String], searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], - prioritizedCommandIDs: [String], query: String, usageHistory: [String: CommandPaletteUsageEntry], queryIsEmpty: Bool, historyTimestamp: TimeInterval, - candidateLimit: Int, resultLimit: Int ) -> [CommandPaletteResolvedSearchMatch] { - guard !searchCorpus.isEmpty, candidateLimit > 0, resultLimit > 0 else { + guard !candidateCommandIDs.isEmpty, resultLimit > 0 else { return [] } - var previewEntries: [CommandPaletteSearchCorpusEntry] = [] - previewEntries.reserveCapacity(min(candidateLimit, searchCorpus.count)) var seenCommandIDs: Set = [] - - for commandID in prioritizedCommandIDs { - guard seenCommandIDs.insert(commandID).inserted, - let entry = searchCorpusByID[commandID] else { - continue - } - previewEntries.append(entry) - if previewEntries.count == candidateLimit { - break - } + let previewEntries: [CommandPaletteSearchCorpusEntry] = candidateCommandIDs.compactMap { commandID in + guard seenCommandIDs.insert(commandID).inserted else { return nil } + return searchCorpusByID[commandID] } - - if previewEntries.count < candidateLimit { - for entry in searchCorpus { - guard seenCommandIDs.insert(entry.payload).inserted else { continue } - previewEntries.append(entry) - if previewEntries.count == candidateLimit { - break - } - } + guard !previewEntries.isEmpty else { + return [] } let matches = commandPaletteResolvedSearchMatches( @@ -3455,7 +3447,6 @@ struct ContentView: View { let usageHistory = commandPaletteUsageHistoryByCommandId let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty let historyTimestamp = Date().timeIntervalSince1970 - commandPaletteSearchHistoryTimestamp = historyTimestamp commandPalettePendingActivation = nil cancelCommandPaletteSearch() if cachedCommandPaletteResults.isEmpty { @@ -3474,9 +3465,22 @@ struct ContentView: View { commandPaletteResolvedSearchScope = scope commandPaletteResolvedSearchFingerprint = fingerprint isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults( + cachedCommandPaletteResults, + scope: scope, + fingerprint: fingerprint + ) commandPaletteResultsRevision &+= 1 return } + refreshPendingCommandPaletteVisibleResults( + scope: scope, + fingerprint: fingerprint, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) isCommandPaletteSearchPending = true commandPaletteSearchTask = Task.detached(priority: .userInitiated) { @@ -3515,6 +3519,11 @@ struct ContentView: View { commandPaletteResolvedSearchScope = scope commandPaletteResolvedSearchFingerprint = fingerprint isCommandPaletteSearchPending = false + setCommandPaletteVisibleResults( + cachedCommandPaletteResults, + scope: scope, + fingerprint: fingerprint + ) if Self.commandPalettePendingActivationRequestID(pendingActivation) == requestID { commandPalettePendingActivation = nil } @@ -5352,12 +5361,14 @@ struct ContentView: View { commandPaletteSearchCorpusByID = [:] commandPaletteSearchCommandsByID = [:] cachedCommandPaletteResults = [] + commandPaletteVisibleResults = [] + commandPaletteVisibleResultsScope = nil + commandPaletteVisibleResultsFingerprint = nil cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID commandPaletteResolvedSearchScope = nil commandPaletteResolvedSearchFingerprint = nil - commandPaletteSearchHistoryTimestamp = 0 isCommandPaletteSearchPending = false commandPalettePendingActivation = nil commandPaletteResultsRevision &+= 1 From fdf2212c88fb6b89c8a7025cb4efd5fa05b8bfca Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:28:18 -0800 Subject: [PATCH 07/15] Add regression test for command preview corpus --- Sources/ContentView.swift | 19 ++++++++++ .../CommandPaletteSearchEngineTests.swift | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5db8359a..9701ca2e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3434,6 +3434,25 @@ struct ContentView: View { return Array(matches.prefix(resultLimit)) } + nonisolated static func commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: [CommandPaletteSearchCorpusEntry], + candidateCommandIDs: [String], + searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], + query: String, + resultLimit: Int + ) -> [String] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + return commandPalettePreviewSearchMatches( + candidateCommandIDs: candidateCommandIDs, + searchCorpusByID: searchCorpusByID, + query: query, + usageHistory: [:], + queryIsEmpty: preparedQuery.isEmpty, + historyTimestamp: 0, + resultLimit: resultLimit + ).map(\.commandID) + } + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 32d8ef82..7ce4ef0c 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -219,6 +219,42 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertGreaterThanOrEqual(cancellationChecks, 4) } + func testCommandPreviewSearchUsesFullCommandCorpus() { + let entries = [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + ] + let corpus = entries.map { entry in + CommandPaletteSearchCorpusEntry( + payload: entry.id, + rank: entry.rank, + title: entry.title, + searchableTexts: entry.searchableTexts + ) + } + let corpusByID = Dictionary(uniqueKeysWithValues: corpus.map { ($0.payload, $0) }) + + let previewCommandIDs = ContentView.commandPaletteCommandPreviewMatchCommandIDsForTests( + searchCorpus: corpus, + candidateCommandIDs: ["command.find"], + searchCorpusByID: corpusByID, + query: "finde", + resultLimit: 48 + ) + + XCTAssertEqual(previewCommandIDs.first, "command.finder") + } + func testResolvedSelectionIndexPrefersAnchoredCommand() { let resultIDs = ["command.0", "command.1", "command.2"] From def9310e7ecff4dd0dcc7214abd928b05795efc5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:28:40 -0800 Subject: [PATCH 08/15] Fix command preview search for new queries --- Sources/ContentView.swift | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9701ca2e..3ccfed8f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3380,6 +3380,8 @@ struct ContentView: View { } let previewMatches = Self.commandPalettePreviewSearchMatches( + scope: scope, + searchCorpus: commandPaletteSearchCorpus, candidateCommandIDs: candidateCommandIDs, searchCorpusByID: commandPaletteSearchCorpusByID, query: query, @@ -3400,6 +3402,8 @@ struct ContentView: View { } nonisolated private static func commandPalettePreviewSearchMatches( + scope: CommandPaletteListScope, + searchCorpus: [CommandPaletteSearchCorpusEntry], candidateCommandIDs: [String], searchCorpusByID: [String: CommandPaletteSearchCorpusEntry], query: String, @@ -3408,7 +3412,25 @@ struct ContentView: View { historyTimestamp: TimeInterval, resultLimit: Int ) -> [CommandPaletteResolvedSearchMatch] { - guard !candidateCommandIDs.isEmpty, resultLimit > 0 else { + guard resultLimit > 0 else { + return [] + } + + if scope == .commands { + let matches = commandPaletteResolvedSearchMatches( + searchCorpus: searchCorpus, + query: query, + usageHistory: usageHistory, + queryIsEmpty: queryIsEmpty, + historyTimestamp: historyTimestamp + ) + guard matches.count > resultLimit else { + return matches + } + return Array(matches.prefix(resultLimit)) + } + + guard !candidateCommandIDs.isEmpty else { return [] } @@ -3443,6 +3465,8 @@ struct ContentView: View { ) -> [String] { let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) return commandPalettePreviewSearchMatches( + scope: .commands, + searchCorpus: searchCorpus, candidateCommandIDs: candidateCommandIDs, searchCorpusByID: searchCorpusByID, query: query, From b7ae0298011b6ee33067b355056527d7dcac138f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:19:37 -0800 Subject: [PATCH 09/15] Add regression test for omitted-character command matches --- .../CommandPaletteSearchEngineTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 7ce4ef0c..9912015c 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -255,6 +255,28 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertEqual(previewCommandIDs.first, "command.finder") } + func testSearchMatchesSingleOmittedCharacterInCommandWordPrefix() { + let entries = [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + ] + + XCTAssertEqual( + optimizedResults(entries: entries, query: "findr").first?.id, + "command.finder" + ) + } + func testResolvedSelectionIndexPrefersAnchoredCommand() { let resultIDs = ["command.0", "command.1", "command.2"] From 15080c931a34913ed0c3c7e73d715de7d6d4a199 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:21:53 -0800 Subject: [PATCH 10/15] Support omitted-character command word matches --- Sources/ContentView.swift | 134 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3ccfed8f..7f2bd425 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6035,6 +6035,13 @@ enum CommandPaletteSwitcherSearchIndexer { enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + private struct SingleOmittedCharacterWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let omittedIndex: Int + } + struct PreparedQuery { let normalizedText: String let tokens: [String] @@ -6120,6 +6127,11 @@ enum CommandPaletteFuzzyMatcher { continue } + if let omittedCharacterPrefix = singleOmittedCharacterWordPrefixMatch(token: token, candidate: loweredCandidate) { + matched.formUnion(omittedCharacterPrefix.matchedIndices) + continue + } + if let initialism = initialismMatchIndices(token: token, candidate: loweredCandidate) { matched.formUnion(initialism) continue @@ -6160,6 +6172,12 @@ enum CommandPaletteFuzzyMatcher { if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) } + if let omittedCharacterPrefixScore = singleOmittedCharacterWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? omittedCharacterPrefixScore, omittedCharacterPrefixScore) + } if let range = candidate.range(of: token) { let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) @@ -6220,6 +6238,33 @@ enum CommandPaletteFuzzyMatcher { return best } + private static func singleOmittedCharacterWordPrefixScore( + tokenChars: [Character], + candidateChars: [Character] + ) -> Int? { + guard tokenChars.count >= 4 else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + guard let match = singleOmittedCharacterWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let lengthPenalty = max(0, match.segmentLength - tokenChars.count - 1) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateChars.count - match.segmentLength) + let omissionPenalty = max(0, match.omittedIndex - match.segmentStart) * 10 + let score = 5000 - distancePenalty - lengthPenalty - trailingPenalty - omissionPenalty + best = max(best ?? score, score) + } + + return best + } + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { guard !tokenChars.isEmpty else { return nil } let segments = wordSegments(candidateChars) @@ -6387,6 +6432,95 @@ enum CommandPaletteFuzzyMatcher { return matchedIndices } + private static func singleOmittedCharacterWordPrefixMatch( + token: String, + candidate: String + ) -> SingleOmittedCharacterWordPrefixMatch? { + singleOmittedCharacterWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleOmittedCharacterWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleOmittedCharacterWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleOmittedCharacterWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleOmittedCharacterWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = 5000 + - (match.segmentStart * 8) + - (max(0, match.segmentLength - tokenChars.count - 1) * 6) + - (max(0, candidateChars.count - match.segmentLength)) + - (max(0, match.omittedIndex - match.segmentStart) * 10) + + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleOmittedCharacterWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleOmittedCharacterWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength >= tokenChars.count + 1 else { return nil } + + var tokenIndex = 0 + var candidateIndex = segment.start + var omittedIndex: Int? + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count, candidateIndex < segment.end { + if candidateChars[candidateIndex] == tokenChars[tokenIndex] { + matchedIndices.insert(candidateIndex) + tokenIndex += 1 + candidateIndex += 1 + continue + } + + guard omittedIndex == nil else { return nil } + omittedIndex = candidateIndex + candidateIndex += 1 + } + + guard tokenIndex == tokenChars.count else { return nil } + let resolvedOmittedIndex: Int + if let omittedIndex { + resolvedOmittedIndex = omittedIndex + } else { + guard candidateIndex < segment.end else { return nil } + resolvedOmittedIndex = candidateIndex + } + + return SingleOmittedCharacterWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + omittedIndex: resolvedOmittedIndex + ) + } + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { var segments: [(start: Int, end: Int)] = [] var index = 0 From a84e6304c916bc6f81b7a92f5c848f97c479c20b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:29:29 -0800 Subject: [PATCH 11/15] Add regression tests for single-edit command matches --- .../CommandPaletteSearchEngineTests.swift | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 9912015c..8b393ac1 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -93,6 +93,29 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } } + private func makeFinderCommandEntries() -> [FixtureEntry] { + [ + FixtureEntry( + id: "command.find", + rank: 0, + title: "Find...", + searchableTexts: ["Find...", "Search", "find", "search"] + ), + FixtureEntry( + id: "command.finder", + rank: 1, + title: "Open Current Directory in Finder", + searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] + ), + FixtureEntry( + id: "command.filter", + rank: 2, + title: "Filter Sidebar Items", + searchableTexts: ["Filter Sidebar Items", "Sidebar", "filter", "sidebar", "items"] + ), + ] + } + private func optimizedResults( entries: [FixtureEntry], query: String @@ -256,20 +279,7 @@ final class CommandPaletteSearchEngineTests: XCTestCase { } func testSearchMatchesSingleOmittedCharacterInCommandWordPrefix() { - let entries = [ - FixtureEntry( - id: "command.find", - rank: 0, - title: "Find...", - searchableTexts: ["Find...", "Search", "find", "search"] - ), - FixtureEntry( - id: "command.finder", - rank: 1, - title: "Open Current Directory in Finder", - searchableTexts: ["Open Current Directory in Finder", "Terminal", "finder", "directory", "open"] - ), - ] + let entries = makeFinderCommandEntries() XCTAssertEqual( optimizedResults(entries: entries, query: "findr").first?.id, @@ -277,6 +287,42 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } + func testSearchMatchesSingleInsertedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "findder").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleSubstitutedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "fander").first?.id, + "command.finder" + ) + } + + func testSearchMatchesSingleTransposedCharacterInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertEqual( + optimizedResults(entries: entries, query: "fidner").first?.id, + "command.finder" + ) + } + + func testSearchRejectsMultipleEditsInCommandWordPrefix() { + let entries = makeFinderCommandEntries() + + XCTAssertNotEqual( + optimizedResults(entries: entries, query: "fadnr").first?.id, + "command.finder" + ) + } + func testResolvedSelectionIndexPrefersAnchoredCommand() { let resultIDs = ["command.0", "command.1", "command.2"] From 03e1fcf6c56f179a4c48da5695000a1e8cc3fba8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:36:32 -0800 Subject: [PATCH 12/15] Generalize command word fuzzy matching to one edit --- Sources/ContentView.swift | 254 +++++++++++++++++++++++++++----------- 1 file changed, 182 insertions(+), 72 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7f2bd425..af4fec04 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6035,11 +6035,33 @@ enum CommandPaletteSwitcherSearchIndexer { enum CommandPaletteFuzzyMatcher { private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - private struct SingleOmittedCharacterWordPrefixMatch { + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { let matchedIndices: Set let segmentStart: Int let segmentLength: Int - let omittedIndex: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind } struct PreparedQuery { @@ -6127,8 +6149,8 @@ enum CommandPaletteFuzzyMatcher { continue } - if let omittedCharacterPrefix = singleOmittedCharacterWordPrefixMatch(token: token, candidate: loweredCandidate) { - matched.formUnion(omittedCharacterPrefix.matchedIndices) + if let singleEditPrefix = singleEditWordPrefixMatch(token: token, candidate: loweredCandidate) { + matched.formUnion(singleEditPrefix.matchedIndices) continue } @@ -6172,11 +6194,11 @@ enum CommandPaletteFuzzyMatcher { if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) } - if let omittedCharacterPrefixScore = singleOmittedCharacterWordPrefixScore( + if let singleEditPrefixScore = singleEditWordPrefixScore( tokenChars: tokenChars, candidateChars: candidateChars ) { - bestScore = max(bestScore ?? omittedCharacterPrefixScore, omittedCharacterPrefixScore) + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) } if let range = candidate.range(of: token) { @@ -6238,31 +6260,33 @@ enum CommandPaletteFuzzyMatcher { return best } - private static func singleOmittedCharacterWordPrefixScore( + private static func singleEditWordPrefixScore( tokenChars: [Character], candidateChars: [Character] ) -> Int? { - guard tokenChars.count >= 4 else { return nil } - - var best: Int? - for segment in wordSegments(candidateChars) { - guard let match = singleOmittedCharacterWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let lengthPenalty = max(0, match.segmentLength - tokenChars.count - 1) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateChars.count - match.segmentLength) - let omissionPenalty = max(0, match.omittedIndex - match.segmentStart) * 10 - let score = 5000 - distancePenalty - lengthPenalty - trailingPenalty - omissionPenalty - best = max(best ?? score, score) + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } - return best + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty } private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { @@ -6299,9 +6323,10 @@ enum CommandPaletteFuzzyMatcher { candidateChars: [Character], candidateStart: Int ) -> Bool { - guard length > 0 else { return false } + guard length >= 0 else { return false } guard tokenStart + length <= tokenChars.count else { return false } guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } for offset in 0.. SingleOmittedCharacterWordPrefixMatch? { - singleOmittedCharacterWordPrefixMatch( + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( tokenChars: Array(token), candidateChars: Array(candidate) ) } - private static func singleOmittedCharacterWordPrefixMatch( + private static func singleEditWordPrefixMatch( tokenChars: [Character], candidateChars: [Character] - ) -> SingleOmittedCharacterWordPrefixMatch? { + ) -> SingleEditWordPrefixMatch? { guard tokenChars.count >= 4 else { return nil } - var bestMatch: SingleOmittedCharacterWordPrefixMatch? + var bestMatch: SingleEditWordPrefixMatch? var bestScore: Int? for segment in wordSegments(candidateChars) { - guard let match = singleOmittedCharacterWordPrefixMatch( + guard let match = singleEditWordPrefixMatch( tokenChars: tokenChars, candidateChars: candidateChars, segment: segment @@ -6460,12 +6485,7 @@ enum CommandPaletteFuzzyMatcher { continue } - let score = 5000 - - (match.segmentStart * 8) - - (max(0, match.segmentLength - tokenChars.count - 1) * 6) - - (max(0, candidateChars.count - match.segmentLength)) - - (max(0, match.omittedIndex - match.segmentStart) * 10) - + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) if let bestScore, score <= bestScore { continue } @@ -6476,49 +6496,139 @@ enum CommandPaletteFuzzyMatcher { return bestMatch } - private static func singleOmittedCharacterWordPrefixMatch( + private static func singleEditWordPrefixMatch( tokenChars: [Character], candidateChars: [Character], segment: (start: Int, end: Int) - ) -> SingleOmittedCharacterWordPrefixMatch? { + ) -> SingleEditWordPrefixMatch? { guard tokenChars.count >= 4 else { return nil } let segmentLength = segment.end - segment.start - guard segmentLength >= tokenChars.count + 1 else { return nil } + guard segmentLength + 1 >= tokenChars.count else { return nil } - var tokenIndex = 0 - var candidateIndex = segment.start - var omittedIndex: Int? - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count, candidateIndex < segment.end { - if candidateChars[candidateIndex] == tokenChars[tokenIndex] { - matchedIndices.insert(candidateIndex) - tokenIndex += 1 - candidateIndex += 1 - continue - } - - guard omittedIndex == nil else { return nil } - omittedIndex = candidateIndex - candidateIndex += 1 + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 } - guard tokenIndex == tokenChars.count else { return nil } - let resolvedOmittedIndex: Int - if let omittedIndex { - resolvedOmittedIndex = omittedIndex - } else { - guard candidateIndex < segment.end else { return nil } - resolvedOmittedIndex = candidateIndex + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) } - return SingleOmittedCharacterWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - omittedIndex: resolvedOmittedIndex - ) + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil } private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { From 9d8a0c6800f77757041c95f1bb8ac3c72e5509fe Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:51:14 -0800 Subject: [PATCH 13/15] Add regression tests for palette pending refresh behavior --- .../CommandPaletteSearchEngineTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 8b393ac1..6d2a0df2 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -387,6 +387,61 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) } + func testSelectionAnchorTracksVisiblePendingSelection() { + let resultIDs = ["command.0", "command.1", "command.2"] + let visibleAnchor = ContentView.commandPaletteSelectionAnchorCommandID( + selectedIndex: 2, + resultIDs: resultIDs + ) + + XCTAssertEqual( + ContentView.commandPaletteResolvedPendingActivation( + .selected( + requestID: 41, + fallbackSelectedIndex: 0, + preferredCommandID: visibleAnchor + ), + requestID: 41, + resultIDs: resultIDs + ), + .selected(index: 2) + ) + } + + func testPreviewCandidateCommandIDsAreBounded() { + let resultIDs = (0..<500).map { "command.\($0)" } + + let previewCandidateIDs = ContentView.commandPalettePreviewCandidateCommandIDs( + resultIDs: resultIDs, + limit: 192 + ) + + XCTAssertEqual(previewCandidateIDs.count, 192) + XCTAssertEqual(previewCandidateIDs.first, "command.0") + XCTAssertEqual(previewCandidateIDs.last, "command.191") + } + + func testSynchronousSeedRunsOnlyWhenScopeChanges() { + XCTAssertTrue( + ContentView.commandPaletteShouldSynchronouslySeedResults( + visibleResultsScope: nil, + nextScope: .commands + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldSynchronouslySeedResults( + visibleResultsScope: .commands, + nextScope: .commands + ) + ) + XCTAssertTrue( + ContentView.commandPaletteShouldSynchronouslySeedResults( + visibleResultsScope: .commands, + nextScope: .switcher + ) + ) + } + func testCommandContextFingerprintTracksExactContextValues() { let base = ContentView.commandPaletteContextFingerprint( boolValues: [ From cc42ea434f1ba7f114e198949f0de4968f3f42ee Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:51:28 -0800 Subject: [PATCH 14/15] Fix palette pending refresh selection and preview work --- Sources/ContentView.swift | 56 +++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index af4fec04..85fb7fbc 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1598,6 +1598,7 @@ struct ContentView: View { private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" private static let commandPaletteCommandsPrefix = ">" private static let commandPaletteVisiblePreviewResultLimit = 48 + private static let commandPaletteVisiblePreviewCandidateLimit = 192 private static let minimumSidebarWidth: CGFloat = 186 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 @@ -3374,7 +3375,10 @@ struct ContentView: View { let candidateCommandIDs: [String] if commandPaletteVisibleResultsScope == scope, commandPaletteVisibleResultsFingerprint == fingerprint { - candidateCommandIDs = commandPaletteVisibleResults.map(\.id) + candidateCommandIDs = Self.commandPalettePreviewCandidateCommandIDs( + resultIDs: commandPaletteVisibleResults.map(\.id), + limit: Self.commandPaletteVisiblePreviewCandidateLimit + ) } else { candidateCommandIDs = [] } @@ -3477,6 +3481,22 @@ struct ContentView: View { ).map(\.commandID) } + static func commandPalettePreviewCandidateCommandIDs( + resultIDs: [String], + limit: Int + ) -> [String] { + guard limit > 0 else { return [] } + guard resultIDs.count > limit else { return resultIDs } + return Array(resultIDs.prefix(limit)) + } + + static func commandPaletteShouldSynchronouslySeedResults( + visibleResultsScope: CommandPaletteListScope?, + nextScope: CommandPaletteListScope + ) -> Bool { + visibleResultsScope != nextScope + } + private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh) @@ -3492,7 +3512,10 @@ struct ContentView: View { let historyTimestamp = Date().timeIntervalSince1970 commandPalettePendingActivation = nil cancelCommandPaletteSearch() - if cachedCommandPaletteResults.isEmpty { + if Self.commandPaletteShouldSynchronouslySeedResults( + visibleResultsScope: commandPaletteVisibleResultsScope, + nextScope: scope + ) { let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, query: query, @@ -4953,6 +4976,15 @@ struct ContentView: View { return min(max(fallbackSelectedIndex, 0), resultIDs.count - 1) } + static func commandPaletteSelectionAnchorCommandID( + selectedIndex: Int, + resultIDs: [String] + ) -> String? { + guard !resultIDs.isEmpty else { return nil } + let resolvedIndex = min(max(selectedIndex, 0), resultIDs.count - 1) + return resultIDs[resolvedIndex] + } + static func commandPalettePendingActivationRequestID( _ pendingActivation: CommandPalettePendingActivation? ) -> UInt64? { @@ -5080,13 +5112,19 @@ struct ContentView: View { } } + private func syncCommandPaletteSelectionAnchor(resultIDs: [String]) { + commandPaletteSelectionAnchorCommandID = Self.commandPaletteSelectionAnchorCommandID( + selectedIndex: commandPaletteSelectedResultIndex, + resultIDs: resultIDs + ) + } + private func syncCommandPaletteSelectionAnchorFromCurrentResults() { - guard !cachedCommandPaletteResults.isEmpty else { - commandPaletteSelectionAnchorCommandID = nil - return - } - let selectedIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count) - commandPaletteSelectionAnchorCommandID = cachedCommandPaletteResults[selectedIndex].id + syncCommandPaletteSelectionAnchor(resultIDs: cachedCommandPaletteResults.map(\.id)) + } + + private func syncCommandPaletteSelectionAnchorFromVisibleResults() { + syncCommandPaletteSelectionAnchor(resultIDs: commandPaletteVisibleResults.map(\.id)) } private func moveCommandPaletteSelection(by delta: Int) { @@ -5099,6 +5137,8 @@ struct ContentView: View { commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) if commandPaletteHasCurrentResolvedResults { syncCommandPaletteSelectionAnchorFromCurrentResults() + } else { + syncCommandPaletteSelectionAnchorFromVisibleResults() } syncCommandPaletteDebugStateForObservedWindow() } From 60b290cafc2540677f6af1ca327cd43158238f22 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:53:44 -0800 Subject: [PATCH 15/15] Fix palette refresh review follow-ups --- Sources/ContentView.swift | 8 +++----- cmuxTests/CommandPaletteSearchEngineTests.swift | 12 ++---------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 85fb7fbc..a592e491 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3491,10 +3491,9 @@ struct ContentView: View { } static func commandPaletteShouldSynchronouslySeedResults( - visibleResultsScope: CommandPaletteListScope?, - nextScope: CommandPaletteListScope + hasVisibleResultsForScope: Bool ) -> Bool { - visibleResultsScope != nextScope + !hasVisibleResultsForScope } private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) { @@ -3513,8 +3512,7 @@ struct ContentView: View { commandPalettePendingActivation = nil cancelCommandPaletteSearch() if Self.commandPaletteShouldSynchronouslySeedResults( - visibleResultsScope: commandPaletteVisibleResultsScope, - nextScope: scope + hasVisibleResultsForScope: commandPaletteVisibleResultsScope == scope ) { let matches = Self.commandPaletteResolvedSearchMatches( searchCorpus: searchCorpus, diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 6d2a0df2..fd9ada43 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -424,20 +424,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { func testSynchronousSeedRunsOnlyWhenScopeChanges() { XCTAssertTrue( ContentView.commandPaletteShouldSynchronouslySeedResults( - visibleResultsScope: nil, - nextScope: .commands + hasVisibleResultsForScope: false ) ) XCTAssertFalse( ContentView.commandPaletteShouldSynchronouslySeedResults( - visibleResultsScope: .commands, - nextScope: .commands - ) - ) - XCTAssertTrue( - ContentView.commandPaletteShouldSynchronouslySeedResults( - visibleResultsScope: .commands, - nextScope: .switcher + hasVisibleResultsForScope: true ) ) }