diff --git a/DATA_AND_PRIVACY.md b/DATA_AND_PRIVACY.md
index e7afe68017..f92231615d 100644
--- a/DATA_AND_PRIVACY.md
+++ b/DATA_AND_PRIVACY.md
@@ -145,6 +145,8 @@ Thank you for using PowerToys!
| Microsoft.PowerToys.CmdPalHotkeySummoned | Logs when the Command Palette is summoned via hotkey, distinguishing between global and context-specific hotkeys. |
| Microsoft.PowerToys.CmdPalInvokeResult | Records the result type of a Command Palette invocation. |
| Microsoft.PowerToys.CmdPalProcessStarted | Triggered when the Command Palette process is started. |
+| Microsoft.PowerToys.CmdPal_SearchResults | Triggered when a search is triggered on the main list, including query length, result count, and duration. |
+| Microsoft.PowerToys.CmdPal_SearchResultSelected | Triggered when a search result is selected, including query length, selection index, and result tier. |
### Crop and lock
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InternalListHelpers.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InternalListHelpers.cs
index eb2ad98aef..bf0e4479af 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InternalListHelpers.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/InternalListHelpers.cs
@@ -60,6 +60,84 @@ public static partial class InternalListHelpers
}
}
+ // Minimum item count before the parallel path earns back its partitioning and merge overhead,
+ // which keeps commands and small app sets on the serial path.
+ private const int ParallelScoringThreshold = 512;
+
+ ///
+ /// Order-preserving parallel variant of that scores
+ /// contiguous index ranges on separate threads and concatenates them back in partition order,
+ /// producing a pre-sort buffer identical to the serial path. The scoring function has to be
+ /// pure per item, since each item is scored by exactly one thread.
+ ///
+ public static RoScored[] FilterListWithScoresParallel(
+ IReadOnlyList? items,
+ in FuzzyQuery query,
+ in ScoringFunction scoreFunction)
+ {
+ if (items is null || items.Count == 0)
+ {
+ return [];
+ }
+
+ var count = items.Count;
+ var partitions = Math.Min(Environment.ProcessorCount, Math.Max(1, count / ParallelScoringThreshold));
+
+ if (count < ParallelScoringThreshold || partitions <= 1)
+ {
+ return FilterListWithScores(items, query, scoreFunction);
+ }
+
+ // Copy the by-ref parameters into locals so the parallel body can capture them. FuzzyQuery
+ // is immutable, so one shared copy is safe to read from every thread.
+ var q = query;
+ var fn = scoreFunction;
+ var source = items;
+
+ var partitionResults = new List>[partitions];
+
+ System.Threading.Tasks.Parallel.For(0, partitions, p =>
+ {
+ var start = (int)((long)p * count / partitions);
+ var end = (int)((long)(p + 1) * count / partitions);
+
+ var local = new List>(end - start);
+ for (var i = start; i < end; i++)
+ {
+ var item = source[i];
+ var score = fn(in q, item);
+ if (score > 0)
+ {
+ local.Add(new RoScored(item, score));
+ }
+ }
+
+ partitionResults[p] = local;
+ });
+
+ var total = 0;
+ for (var p = 0; p < partitions; p++)
+ {
+ total += partitionResults[p].Count;
+ }
+
+ // Contiguous ranges merged in partition order reproduce the serial buffer's exact
+ // enumeration order.
+ var buffer = GC.AllocateUninitializedArray>(total);
+ var pos = 0;
+ for (var p = 0; p < partitions; p++)
+ {
+ var list = partitionResults[p];
+ for (var j = 0; j < list.Count; j++)
+ {
+ buffer[pos++] = list[j];
+ }
+ }
+
+ Array.Sort(buffer, 0, total, default(RoScoredDescendingComparer));
+ return buffer;
+ }
+
private static void GrowBuffer(ref RoScored[] buffer, int count)
{
var newBuffer = ArrayPool>.Shared.Rent(buffer.Length * 2);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
index d2c7be8dd3..a3b35cf712 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs
@@ -6,6 +6,7 @@
#define CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
*/
+using System.Collections.Immutable;
using System.Collections.Specialized;
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
@@ -49,6 +50,10 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly ScoringFunction _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
+ // All main-page search telemetry state and emission is owned by this dedicated type, keeping
+ // MainListPage responsible for producing results rather than for tracking telemetry bookkeeping.
+ private readonly MainListPageSearchTelemetry _searchTelemetry = new();
+
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
@@ -63,16 +68,34 @@ public sealed partial class MainListPage : DynamicListPage,
private RoScored[]? _filteredItems;
private RoScored[]? _filteredApps;
- // Keep as IEnumerable for deferred execution. Fallback item titles are updated
- // asynchronously, so scoring must happen lazily when GetItems is called.
- private IEnumerable>? _scoredFallbackItems;
+ // Published with _filteredApps so filtering uses the query that produced the scores.
+ private int _filteredAppsQueryLength;
+
+ // Global/special fallbacks are scored on the render path, not at keystroke time, because
+ // their titles resolve asynchronously. We snapshot the source list and query together so a
+ // superseding keystroke replaces both atomically.
+ private IReadOnlyList? _globalFallbackSources;
+ private FuzzyQuery _globalFallbackQuery;
+
+ // Common fallbacks use query-independent scores, so freezing them is safe; only their live
+ // titles decide whether they render.
private IEnumerable>? _fallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
+ // Last per-provider settings we reacted to, so a settings reload can tell whether any
+ // provider's search weight actually changed and only then re-rank the active query.
+ private ImmutableDictionary? _lastProviderSettingsSnapshot;
+
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
+ // Longest query to filter fuzzy app matches on. This prevents weak app matches on short queries.
+ private const int ShortQueryAppFilterMaxLength = 2;
+
+ // Minimum tier an app must reach to appear for a short query.
+ private const RankTier ShortQueryAppFilterMinTier = RankTier.AcronymWordBoundary;
+
private InterlockedBoolean _fullRefreshRequested;
private InterlockedBoolean _refreshRunning;
private InterlockedBoolean _refreshRequested;
@@ -100,7 +123,7 @@ public sealed partial class MainListPage : DynamicListPage,
_appStateService = appStateService;
_tlcManager = topLevelCommandManager;
_fuzzyMatcherProvider = fuzzyMatcherProvider;
- _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current);
+ _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current, ResolveProviderSearchWeight);
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settingsService.Settings.FallbackRanks);
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
@@ -259,22 +282,125 @@ public sealed partial class MainListPage : DynamicListPage,
private IListItem[] GetSearchViewItems()
{
- var validScoredFallbacks = _scoredFallbackItems?
- .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
- .ToList();
+ // Score global fallbacks against their current titles so a fallback whose title
+ // resolved after first paint gets the right score. Cheap: only a handful are configured.
+ var validScoredFallbacks = ScoreDeferredFallbacks(_globalFallbackSources, _globalFallbackQuery, _scoringFunction);
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
- return MainListPageResultFactory.Create(
+ // Remove fuzzy-only app matches for short queries so frecency-boosted weak matches don't
+ // appear while typing.
+ var filteredApps = FilterAppsForShortQueries(_filteredApps, _filteredAppsQueryLength);
+
+ var result = MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
- _filteredApps,
+ filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
+
+ // Snapshot the rendered order plus every scored input and the query length together, so
+ // selection telemetry resolves an invoked item's rank, tier, and query length from this one
+ // generation off the hot path. These are plain reference assignments - no extra allocation.
+ _searchTelemetry.CaptureSearchView(
+ result,
+ _filteredItems,
+ _filteredApps,
+ validScoredFallbacks,
+ _fallbackItems,
+ SearchText?.Length ?? 0);
+
+ return result;
+ }
+
+ // Scores the current global-fallback snapshot against its query, dropping any whose title is
+ // still empty. Static so it can be unit tested with a fake slow source.
+ internal static List>? ScoreDeferredFallbacks(
+ IReadOnlyList? sources,
+ in FuzzyQuery query,
+ ScoringFunction scoringFunction)
+ {
+ if (sources is null || sources.Count == 0)
+ {
+ return null;
+ }
+
+ var scored = InternalListHelpers.FilterListWithScores(sources, query, scoringFunction);
+ if (scored.Length == 0)
+ {
+ return null;
+ }
+
+ List>? valid = null;
+ foreach (var s in scored)
+ {
+ if (string.IsNullOrWhiteSpace(s.Item.Title))
+ {
+ continue;
+ }
+
+ valid ??= new List>(scored.Length);
+ valid.Add(s);
+ }
+
+ return valid;
+ }
+
+ // Returns a filtered view of the scored array without reordering it.
+ internal static IList>? FilterAppsForShortQueries(
+ RoScored[]? scoredApps,
+ int queryLength)
+ {
+ if (scoredApps is null || scoredApps.Length == 0)
+ {
+ return scoredApps;
+ }
+
+ if (queryLength <= 0 || queryLength > ShortQueryAppFilterMaxLength)
+ {
+ return scoredApps;
+ }
+
+ var keep = GetHighConfidenceAppsCount(scoredApps, ShortQueryAppFilterMinTier);
+ return keep == scoredApps.Length
+ ? scoredApps
+ : new ArraySegment>(scoredApps, 0, keep);
+ }
+
+ // Qualifying apps form a contiguous prefix because the array is already sorted by score.
+ internal static int GetHighConfidenceAppsCount(IReadOnlyList> scored, RankTier minTier)
+ {
+ var min = (int)minTier;
+ for (var i = 0; i < scored.Count; i++)
+ {
+ if ((int)MainListRanker.TierOf(scored[i].Score) < min)
+ {
+ return i;
+ }
+ }
+
+ return scored.Count;
+ }
+
+ // Applies the short-query filter and result limit to the telemetry count.
+ internal static int GetVisibleAppCount(RoScored[]? scoredApps, int queryLength, int appResultLimit)
+ {
+ if (scoredApps is null || scoredApps.Length == 0)
+ {
+ return 0;
+ }
+
+ var count = scoredApps.Length;
+ if (queryLength > 0 && queryLength <= ShortQueryAppFilterMaxLength)
+ {
+ count = GetHighConfidenceAppsCount(scoredApps, ShortQueryAppFilterMinTier);
+ }
+
+ return Math.Min(count, appResultLimit);
}
private IListItem[] GetDefaultViewItems()
@@ -361,8 +487,12 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItems = null;
_filteredApps = null;
+ _filteredAppsQueryLength = 0;
_fallbackItems = null;
- _scoredFallbackItems = null;
+ _globalFallbackSources = null;
+
+ // Clear the paired query too, so both are reset together.
+ _globalFallbackQuery = default;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
@@ -403,6 +533,10 @@ public sealed partial class MainListPage : DynamicListPage,
if (aliases.CheckAlias(newSearch))
{
+ // An alias query supersedes any normal query whose settled-search telemetry is
+ // still pending in the debounce; drop it so the superseded query never emits.
+ _searchTelemetry.CancelPendingResults();
+
if (_filteredItemsIncludesApps != _includeApps)
{
lock (_tlcManager.TopLevelCommands)
@@ -422,6 +556,17 @@ public sealed partial class MainListPage : DynamicListPage,
}
var commands = _tlcManager.TopLevelCommands;
+
+ // Inputs captured under the lock so the heavy scoring below can run off it. GetItems()
+ // takes the same lock, so it now only contends with the short snapshot and publish sections.
+ IReadOnlyList itemsSource;
+ IReadOnlyList appsSource;
+ IReadOnlyList fallbackSource;
+ IListItem[] globalFallbackSources;
+ bool includeAppsSnapshot;
+ bool tookFullCatalog = false;
+
+ // ===== SNAPSHOT PHASE (under lock) =====
lock (commands)
{
if (token.IsCancellationRequested)
@@ -463,58 +608,37 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
+
+ // Drop any pending settled-search telemetry so a cleared query never emits.
+ _searchTelemetry.ClearSearchView();
+
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
return;
}
- // If the new string doesn't start with the old string, then we can't
- // re-use previous results. Reset _filteredItems, and keep er moving.
- if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
- {
- ClearResults();
- }
+ includeAppsSnapshot = _includeApps;
- // If the internal state has changed, reset _filteredItems to reset the list.
- if (_filteredItemsIncludesApps != _includeApps)
- {
- ClearResults();
- }
+ // A query that doesn't extend the old one, or a change in app inclusion, means we
+ // can't re-use the previous results and have to rebuild from the full catalog. On an
+ // extend we re-score only the previously matched subset.
+ var reset = !newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase)
+ || _filteredItemsIncludesApps != includeAppsSnapshot;
- if (token.IsCancellationRequested)
- {
- return;
- }
+ var prevFilteredItems = reset ? null : _filteredItems;
+ var prevApps = reset ? null : _filteredApps;
+ var prevFallbacks = reset ? null : _fallbackItems;
- var newFilteredItems = Enumerable.Empty();
- var newFallbacks = Enumerable.Empty();
- var newApps = Enumerable.Empty();
-
- if (_filteredItems is not null)
- {
- newFilteredItems = _filteredItems.Select(s => s.Item);
- }
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- if (_filteredApps is not null)
- {
- newApps = _filteredApps.Select(s => s.Item);
- }
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- if (_fallbackItems is not null)
- {
- newFallbacks = _fallbackItems.Select(s => s.Item);
- }
+ IEnumerable newFilteredItems = prevFilteredItems is not null
+ ? prevFilteredItems.Select(s => s.Item)
+ : Enumerable.Empty();
+ IEnumerable newApps = prevApps is not null
+ ? prevApps.Select(s => s.Item)
+ : Enumerable.Empty();
+ IEnumerable newFallbacks = prevFallbacks is not null
+ ? prevFallbacks.Select(s => s.Item)
+ : Enumerable.Empty();
if (token.IsCancellationRequested)
{
@@ -525,6 +649,7 @@ public sealed partial class MainListPage : DynamicListPage,
// with a list of all our commands & apps.
if (!newFilteredItems.Any() && !newApps.Any())
{
+ tookFullCatalog = true;
newFilteredItems = commands.Where(s => !s.IsFallback);
// Fallbacks are always included in the list, even if they
@@ -537,9 +662,7 @@ public sealed partial class MainListPage : DynamicListPage,
return;
}
- _filteredItemsIncludesApps = _includeApps;
-
- if (_includeApps)
+ if (includeAppsSnapshot)
{
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast().ToList();
@@ -562,71 +685,151 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
- var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
+ // Materialize every source while still under the lock, so the scoring passes never
+ // touch the live TopLevelCommands collection or the app provider.
+ itemsSource = MaterializeSource(newFilteredItems);
+ appsSource = MaterializeSource(newApps);
+ fallbackSource = MaterializeSource(newFallbacks);
+ globalFallbackSources = [.. specialFallbacks];
+ }
- // Produce a list of everything that matches the current filter.
- _filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
+ if (token.IsCancellationRequested)
+ {
+ return;
+ }
+
+ // ===== SCORING PHASE (off the lock) =====
+ // The dominant apps pass is parallelized, commands and fallbacks stay serial, and none of
+ // it holds the TopLevelCommands lock any more, so it no longer blocks GetItems()/render.
+ //
+ // Snapshot every scoring input once, up front: the live fields can be swapped mid-pass when
+ // a selection calls WithHistoryItem on another thread, which would mix two frecency
+ // snapshots into one pass or race a parallel thread against an unwarmed history index.
+ var recent = _appStateService.State.RecentCommands;
+ recent.PrewarmIndex();
+ var matcher = _fuzzyMatcherProvider.Current;
+ var settings = _settingsService.Settings;
+ var scoringNow = DateTimeOffset.UtcNow;
+
+ // Precompute from the snapshotted newSearch, not the live SearchText, which a newer
+ // keystroke may already have advanced past.
+ var searchQuery = matcher.PrecomputeQuery(newSearch);
+
+ // Every installed app belongs to the well-known AllApps provider, so its weight is constant
+ // for the whole pass and we resolve it once instead of once per app.
+ var appsProviderWeight = ResolveProviderSearchWeight(settings, AllAppsCommandProvider.WellKnownId);
+ Func commandsProviderLookup = item => ResolveProviderSearchWeight(settings, item);
+ Func appsProviderLookup = _ => appsProviderWeight;
+
+ ScoringFunction commandsScorer = (in FuzzyQuery q, IListItem item) =>
+ ScoreTopLevelItem(in q, item, recent, matcher, commandsProviderLookup, scoringNow);
+ ScoringFunction appsScorer = (in FuzzyQuery q, IListItem item) =>
+ ScoreTopLevelItem(in q, item, recent, matcher, appsProviderLookup, scoringNow);
+
+ var scoredFilteredItems = InternalListHelpers.FilterListWithScores(itemsSource, searchQuery, commandsScorer);
+
+ if (token.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var scoredFallbackItems = InternalListHelpers.FilterListWithScores(fallbackSource, searchQuery, _fallbackScoringFunction);
+
+ if (token.IsCancellationRequested)
+ {
+ return;
+ }
+
+ RoScored[]? scoredApps = null;
+ if (appsSource.Count > 0)
+ {
+ scoredApps = InternalListHelpers.FilterListWithScoresParallel(appsSource, searchQuery, appsScorer);
if (token.IsCancellationRequested)
{
return;
}
-
- IEnumerable newFallbacksForScoring = commands.Where(s => s.IsFallback && configuredGlobalFallbackIds.Contains(s.Id));
- _scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- _fallbackItems = InternalListHelpers.FilterListWithScores(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
-
- if (token.IsCancellationRequested)
- {
- return;
- }
-
- // Produce a list of filtered apps with the appropriate limit
- if (newApps.Any())
- {
- _filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
-
- if (token.IsCancellationRequested)
- {
- return;
- }
- }
+ }
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
- var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
+ var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
#endif
+
+ // ===== PUBLISH PHASE (under lock) =====
+ // The critical section is the field swaps only. Telemetry debounce and refresh throttling
+ // happen after the lock so _searchTelemetryLock never nests under the commands lock.
+ var deterministicResultCount = 0;
+ lock (commands)
+ {
+ // A newer keystroke cancels this token before doing its own work, so a stale snapshot
+ // can never overwrite a newer query's results.
+ if (token.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (tookFullCatalog)
+ {
+ _filteredItemsIncludesApps = includeAppsSnapshot;
+ }
+
+ _filteredItems = scoredFilteredItems;
+ _fallbackItems = scoredFallbackItems;
+
+ // Snapshot the global fallbacks and query, but score them later on the render path,
+ // since their titles are still resolving asynchronously (BeginUpdate, above).
+ _globalFallbackSources = globalFallbackSources;
+ _globalFallbackQuery = searchQuery;
+
+ // With no apps source, publish null so a rebuild clears any stale set, matching the old
+ // ClearResults behavior.
+ _filteredApps = appsSource.Count > 0 ? scoredApps : null;
+
+ // Publish the length with the array so filtering and telemetry use the same query.
+ _filteredAppsQueryLength = _filteredApps is null ? 0 : newSearch.Length;
+
if (isUserInput)
{
- // Make sure that the throttle delay is consistent from the user's perspective, even if filtering
- // takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
- var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
- if (adjustedInterval < TimeSpan.Zero)
- {
- adjustedInterval = TimeSpan.Zero;
- }
-
- RequestRefresh(fullRefresh: true, adjustedInterval);
- }
- else
- {
- RequestRefresh(fullRefresh: true);
+ deterministicResultCount = (_filteredItems?.Length ?? 0)
+ + GetVisibleAppCount(_filteredApps, _filteredAppsQueryLength, AppResultLimit);
}
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
#endif
+ }
- stopwatch.Stop();
+ // Getting here means the swap happened, since the superseded path returns inside the lock.
+ stopwatch.Stop();
+
+ if (isUserInput)
+ {
+ // Queue a settled-search telemetry event. It's debounced so it only fires once the
+ // query settles, and it carries the query LENGTH only, never the text.
+ _searchTelemetry.QueueSearchResults(newSearch.Length, deterministicResultCount, stopwatch.ElapsedMilliseconds);
+
+ // Make sure that the throttle delay is consistent from the user's perspective, even if filtering
+ // takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
+ var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
+ if (adjustedInterval < TimeSpan.Zero)
+ {
+ adjustedInterval = TimeSpan.Zero;
+ }
+
+ RequestRefresh(fullRefresh: true, adjustedInterval);
+ }
+ else
+ {
+ RequestRefresh(fullRefresh: true);
}
}
+ // Materializes a source into a stable, indexable snapshot so scoring can run off the lock.
+ // Anything already an IReadOnlyList passes through; lazy LINQ over live data gets copied.
+ private static IReadOnlyList MaterializeSource(IEnumerable items)
+ => items as IReadOnlyList ?? items.ToArray();
+
private bool ActuallyLoading()
{
var allApps = AllAppsCommandProvider.Page;
@@ -640,7 +843,9 @@ public sealed partial class MainListPage : DynamicListPage,
in FuzzyQuery query,
IListItem topLevelOrAppItem,
IRecentCommandsManager history,
- IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
+ IPrecomputedFuzzyMatcher precomputedFuzzyMatcher,
+ Func? providerWeightLookup = null,
+ DateTimeOffset? now = null)
{
var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title))
@@ -705,14 +910,19 @@ public sealed partial class MainListPage : DynamicListPage,
return 0;
}
- var frecencyWeight = history.GetCommandHistoryWeight(id);
+ var frecencyWeight = history.GetCommandHistoryWeight(id, now ?? DateTimeOffset.UtcNow);
var aliasSubstringBonus = isAliasSubstringMatch && !isAliasMatch ? MainListRanker.AliasSubstringBonus : 0.0;
+ // Per-provider weight is a within-tier nudge only. Resolving it here (rather than in
+ // the tier classifier) guarantees it can never promote an item across a tier boundary.
+ var providerWeight = providerWeightLookup?.Invoke(topLevelOrAppItem) ?? ProviderSearchWeight.Normal;
+ var providerBonus = MainListRanker.ProviderBonus(providerWeight);
+
var withinTier = MainListRanker.WithinTierScore(
lexicalQuality,
frecencyWeight,
aliasSubstringBonus,
- providerBonus: 0.0);
+ providerBonus: providerBonus);
return MainListRanker.Pack(tier, withinTier);
}
@@ -752,6 +962,8 @@ public sealed partial class MainListPage : DynamicListPage,
{
RecentCommands = state.RecentCommands.WithHistoryItem(id),
});
+
+ _searchTelemetry.ReportSelection(topLevelOrAppItem, _resultsSeparator, _fallbacksSeparator);
}
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
@@ -767,6 +979,34 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
+ // Resolves the user-configured per-provider search weight for an item. Top-level commands
+ // carry their own provider id; installed apps all belong to the well-known "AllApps"
+ // provider, so app items are weighted by that provider's setting. The static overloads take a
+ // settings snapshot so the hot path resolves against one captured SettingsModel.
+ private ProviderSearchWeight ResolveProviderSearchWeight(IListItem topLevelOrAppItem)
+ => ResolveProviderSearchWeight(_settingsService.Settings, topLevelOrAppItem);
+
+ private static ProviderSearchWeight ResolveProviderSearchWeight(SettingsModel settings, IListItem topLevelOrAppItem)
+ {
+ var providerId = topLevelOrAppItem is TopLevelViewModel topLevel
+ ? topLevel.CommandProviderId
+ : AllAppsCommandProvider.WellKnownId;
+
+ return ResolveProviderSearchWeight(settings, providerId);
+ }
+
+ private static ProviderSearchWeight ResolveProviderSearchWeight(SettingsModel settings, string providerId)
+ {
+ if (string.IsNullOrEmpty(providerId))
+ {
+ return ProviderSearchWeight.Normal;
+ }
+
+ return settings.ProviderSettings.TryGetValue(providerId, out var providerSettings)
+ ? providerSettings.SearchWeight
+ : ProviderSearchWeight.Normal;
+ }
+
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
public void Receive(UpdateFallbackItemsMessage message)
@@ -778,13 +1018,72 @@ public sealed partial class MainListPage : DynamicListPage,
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) => HotReloadSettings(args);
- private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
+ private void HotReloadSettings(SettingsModel settings)
+ {
+ ShowDetails = settings.ShowAppDetails;
+
+ // A per-provider search-weight change has to reorder the query that is already on screen.
+ // Scoring reads the weight live, but scored results are cached, so without an explicit
+ // re-score the active query keeps its old order until the next keystroke. Detect a weight
+ // change and re-rank the current search in place.
+ var providerSettings = settings.ProviderSettings;
+ var weightsChanged = ProviderWeightsChanged(_lastProviderSettingsSnapshot, providerSettings);
+ _lastProviderSettingsSnapshot = providerSettings;
+
+ if (weightsChanged && !string.IsNullOrEmpty(SearchText))
+ {
+ RerankActiveSearch();
+ }
+ }
+
+ // Re-scores the current query off the UI thread so a settings change (e.g. a per-provider
+ // search-weight change) reorders the results already shown. This reuses the same non-reset
+ // re-score path as an app-inclusion refresh: the retained matches are re-scored with the new
+ // weights, which is sufficient because provider weight only nudges order within a tier and
+ // never changes which items match.
+ private void RerankActiveSearch()
+ {
+ var current = SearchText;
+ if (!string.IsNullOrEmpty(current))
+ {
+ _ = Task.Run(() => UpdateSearchTextCore(current, current, isUserInput: false));
+ }
+ }
+
+ // True when the effective per-provider search weight differs between two snapshots. A provider
+ // absent from a snapshot is treated as Normal, so adding or removing an entry whose weight is
+ // Normal does not count as a change.
+ private static bool ProviderWeightsChanged(
+ ImmutableDictionary? previous,
+ ImmutableDictionary current)
+ {
+ previous ??= ImmutableDictionary.Empty;
+ if (ReferenceEquals(previous, current))
+ {
+ return false;
+ }
+
+ var keys = new HashSet(previous.Keys, StringComparer.Ordinal);
+ keys.UnionWith(current.Keys);
+ foreach (var key in keys)
+ {
+ var previousWeight = previous.TryGetValue(key, out var p) ? p.SearchWeight : ProviderSearchWeight.Normal;
+ var currentWeight = current.TryGetValue(key, out var c) ? c.SearchWeight : ProviderSearchWeight.Normal;
+ if (previousWeight != currentWeight)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_fallbackUpdateManager.Dispose();
+ _searchTelemetry.Dispose();
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageSearchTelemetry.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageSearchTelemetry.cs
new file mode 100644
index 0000000000..050fdb0b90
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageSearchTelemetry.cs
@@ -0,0 +1,248 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.CmdPal.Common.Helpers;
+using Microsoft.CmdPal.Core.Common.Helpers;
+using Microsoft.CmdPal.UI.ViewModels.Messages;
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+
+namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
+
+///
+/// Owns all of the main-page search telemetry state and emission, so
+/// stays responsible for producing results and this type is the single, clearly-identifiable owner
+/// of the (privacy-safe) telemetry. Everything here is opt-in and non-identifying: search events
+/// carry only query LENGTH, result count and latency; selection events carry only query LENGTH, the
+/// invoked item's visible rank and its ranker tier - never the raw query text or item content.
+///
+/// Search events are emitted only when a query settles (trailing-edge debounce) so we never send an
+/// event on every keystroke; selection events are emitted only when the user invokes a result. All
+/// emission is measured at boundaries, never inside the per-item scoring loop.
+///
+internal sealed partial class MainListPageSearchTelemetry : IDisposable
+{
+ private static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(600);
+
+ private readonly ThrottledDebouncedAction _resultsDebounce;
+ private readonly Lock _pendingLock = new();
+ private (int QueryLength, int ResultCount, long LatencyMs) _pendingResults;
+
+ // Snapshots of the most recent rendered search results, read off the hot path (only when the
+ // user invokes a result) to resolve the invoked item's visible rank and ranker tier. The scored
+ // inputs and query length are captured together with the rendered items at render time, so
+ // selection telemetry resolves rank, tier and query length from one coherent generation even
+ // after a newer query has already published fresh scored fields.
+ private IReadOnlyList? _lastViewItems;
+ private IReadOnlyList>? _lastScoredGlobalFallbacks;
+ private RoScored[]? _lastViewFilteredItems;
+ private RoScored[]? _lastViewFilteredApps;
+ private IEnumerable>? _lastViewFallbackItems;
+ private int _lastViewQueryLength;
+
+ public MainListPageSearchTelemetry()
+ {
+ _resultsDebounce = new ThrottledDebouncedAction(EmitPendingResults, SettleDelay);
+ }
+
+ // Stores the latest settled-search metrics and (re)arms the debounce. Only the query LENGTH is
+ // retained - the query text is never stored for telemetry.
+ public void QueueSearchResults(int queryLength, int resultCount, long latencyMs)
+ {
+ lock (_pendingLock)
+ {
+ _pendingResults = (queryLength, resultCount, latencyMs);
+ }
+
+ _resultsDebounce.Invoke();
+ }
+
+ // Snapshots the rendered order plus every scored input and the query length together, so a later
+ // selection resolves an invoked item's rank, tier and query length from this one generation off
+ // the hot path. These are plain reference assignments - no extra allocation.
+ public void CaptureSearchView(
+ IReadOnlyList renderedItems,
+ RoScored[]? filteredItems,
+ RoScored[]? filteredApps,
+ IReadOnlyList>? scoredGlobalFallbacks,
+ IEnumerable>? fallbackItems,
+ int queryLength)
+ {
+ _lastViewItems = renderedItems;
+ _lastScoredGlobalFallbacks = scoredGlobalFallbacks;
+ _lastViewFilteredItems = filteredItems;
+ _lastViewFilteredApps = filteredApps;
+ _lastViewFallbackItems = fallbackItems;
+ _lastViewQueryLength = queryLength;
+ }
+
+ // Drops any pending settled-search event without emitting it. Used when an alias query supersedes
+ // a normal query whose telemetry is still pending in the debounce.
+ public void CancelPendingResults() => _resultsDebounce.Cancel();
+
+ // Drops any pending settled-search event and forgets the last rendered search view, so a cleared
+ // query never emits and a subsequent selection resolves to nothing.
+ public void ClearSearchView()
+ {
+ _resultsDebounce.Cancel();
+ _lastViewItems = null;
+ _lastScoredGlobalFallbacks = null;
+ _lastViewFilteredItems = null;
+ _lastViewFilteredApps = null;
+ _lastViewFallbackItems = null;
+ _lastViewQueryLength = 0;
+ }
+
+ // Emits selection telemetry when the user invokes a result during an active search. Runs only on
+ // invoke (a deliberate, infrequent user action - never on the typing/scoring path) and captures
+ // only non-identifying aggregates: the query LENGTH, the invoked item's visible rank, and its
+ // ranker tier. Nothing is emitted for the default (no-search) view, or when the invoked item is
+ // not among the last rendered search results.
+ public void ReportSelection(IListItem invoked, Separator resultsSeparator, Separator fallbacksSeparator)
+ {
+ // Resolve everything from the last rendered search-view snapshot so the invoked item's rank,
+ // tier, and the reported query length all come from one generation. If the last render was
+ // the default (no-search) view, _lastViewItems is null and nothing is emitted.
+ var lastView = _lastViewItems;
+ if (lastView is null || _lastViewQueryLength <= 0)
+ {
+ return;
+ }
+
+ var index = ResolveVisibleIndex(lastView, invoked, resultsSeparator, fallbacksSeparator);
+ if (index < 0)
+ {
+ return;
+ }
+
+ var packed = (_lastViewFilteredItems ?? Enumerable.Empty>())
+ .Concat(_lastViewFilteredApps ?? Enumerable.Empty>())
+ .Concat(_lastScoredGlobalFallbacks ?? Enumerable.Empty>());
+
+ var tier = ResolveSelectedTier(invoked, packed, _lastViewFallbackItems);
+ if (tier == RankTier.None)
+ {
+ return;
+ }
+
+ WeakReferenceMessenger.Default.Send(BuildSearchSelectedMessage(_lastViewQueryLength, index, tier));
+ }
+
+ private void EmitPendingResults()
+ {
+ (int QueryLength, int ResultCount, long LatencyMs) snapshot;
+ lock (_pendingLock)
+ {
+ snapshot = _pendingResults;
+ }
+
+ if (snapshot.QueryLength <= 0)
+ {
+ return;
+ }
+
+ WeakReferenceMessenger.Default.Send(
+ BuildSearchResultsMessage(snapshot.QueryLength, snapshot.ResultCount, snapshot.LatencyMs));
+ }
+
+ // Builds the settled-search telemetry payload from a query string, capturing only its LENGTH.
+ // Exposed for tests to prove the raw query text is never carried.
+ internal static TelemetrySearchResultsMessage BuildSearchResultsMessage(string query, int resultCount, long latencyMs)
+ => BuildSearchResultsMessage(query?.Length ?? 0, resultCount, latencyMs);
+
+ internal static TelemetrySearchResultsMessage BuildSearchResultsMessage(int queryLength, int resultCount, long latencyMs)
+ {
+ var length = Math.Max(queryLength, 0);
+ var count = Math.Max(resultCount, 0);
+ var latency = latencyMs < 0 ? 0UL : (ulong)latencyMs;
+ return new TelemetrySearchResultsMessage(length, count, count == 0, latency);
+ }
+
+ // Builds the selection telemetry payload, capturing only the query LENGTH, the selected rank,
+ // and the ranker tier. Exposed for tests to prove the raw query text is never carried.
+ internal static TelemetrySearchResultSelectedMessage BuildSearchSelectedMessage(string query, int selectedIndex, RankTier selectedTier)
+ => BuildSearchSelectedMessage(query?.Length ?? 0, selectedIndex, selectedTier);
+
+ internal static TelemetrySearchResultSelectedMessage BuildSearchSelectedMessage(int queryLength, int selectedIndex, RankTier selectedTier)
+ => new(Math.Max(queryLength, 0), selectedIndex, selectedTier);
+
+ // Zero-based visible rank of an invoked item within the rendered results, skipping the section
+ // separators. Returns -1 when the item is not present (e.g. it was invoked from a different view).
+ internal static int ResolveVisibleIndex(IReadOnlyList? renderedResults, IListItem invoked, params IListItem[] separators)
+ {
+ if (renderedResults is null)
+ {
+ return -1;
+ }
+
+ var visible = 0;
+ foreach (var item in renderedResults)
+ {
+ var isSeparator = false;
+ foreach (var separator in separators)
+ {
+ if (ReferenceEquals(item, separator))
+ {
+ isSeparator = true;
+ break;
+ }
+ }
+
+ if (isSeparator)
+ {
+ continue;
+ }
+
+ if (ReferenceEquals(item, invoked))
+ {
+ return visible;
+ }
+
+ visible++;
+ }
+
+ return -1;
+ }
+
+ // Resolves the ranker tier of an invoked item. Packed sources (commands, apps, global
+ // fallbacks) decode their tier via MainListRanker.TierOf; common fallbacks carry rank-based
+ // (non-packed) scores, so they are reported at the fallback floor. Returns None when the item
+ // is not found in any source.
+ internal static RankTier ResolveSelectedTier(
+ IListItem invoked,
+ IEnumerable>? packedResults,
+ IEnumerable>? fallbackResults)
+ {
+ if (packedResults is not null)
+ {
+ foreach (var scored in packedResults)
+ {
+ if (ReferenceEquals(scored.Item, invoked))
+ {
+ return MainListRanker.TierOf(scored.Score);
+ }
+ }
+ }
+
+ if (fallbackResults is not null)
+ {
+ foreach (var scored in fallbackResults)
+ {
+ if (ReferenceEquals(scored.Item, invoked))
+ {
+ return RankTier.FallbackFloor;
+ }
+ }
+ }
+
+ return RankTier.None;
+ }
+
+ public void Dispose() => _resultsDebounce.Dispose();
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs
index 79bbeb3f1d..563121ea5e 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListRanker.cs
@@ -31,6 +31,23 @@ internal static class MainListRanker
// exact alias, which gets its own top tier). Mirrors the previous +1-before-x10 boost.
internal const double AliasSubstringBonus = 10.0;
+ // Magnitude of the per-provider within-tier nudge. Deliberately small - half a point of
+ // lexical quality (LexicalScale = 10) - so a Higher/Lower provider only breaks near-ties
+ // and reorders items that already share a tier. It can NEVER move an item across a tier
+ // boundary because the packed within-tier score is clamped to a single tier's band.
+ internal const double ProviderWeightBonus = 5.0;
+
+ ///
+ /// Maps a per-provider to an additive within-tier
+ /// bonus. Lower subtracts, Normal is neutral, Higher adds. The enum's underlying value is
+ /// the sign of the nudge, so the result is simply the weight times
+ /// . Note the nudge is slightly asymmetric at the tier
+ /// floor: clamps the within-tier score to a non-negative band, so a
+ /// Lower nudge on an item already scoring near 0 (weak match, no history) can clamp to 0
+ /// and read the same as Normal, whereas Higher always applies.
+ ///
+ public static double ProviderBonus(ProviderSearchWeight weight) => (int)weight * ProviderWeightBonus;
+
///
/// Packs a tier and within-tier score into a single descending-sortable integer.
/// Returns 0 for so non-matches are filtered by the
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HistoryItem.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HistoryItem.cs
index 0f8d90acea..554202f9ac 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HistoryItem.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HistoryItem.cs
@@ -2,7 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using CommunityToolkit.Mvvm.ComponentModel;
+using System;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -11,4 +11,13 @@ public record HistoryItem
public required string CommandId { get; init; }
public required int Uses { get; init; }
+
+ ///
+ /// Gets the moment this command was last invoked. Persisted so ranking can apply real
+ /// time-decay instead of the previous list-position heuristic. History written before
+ /// this field existed deserializes to default(DateTimeOffset);
+ /// treats that sentinel as a mild backdate so
+ /// day-one ordering degrades gracefully to Uses-ordering rather than going all-equal.
+ ///
+ public DateTimeOffset LastUsed { get; init; }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs
new file mode 100644
index 0000000000..cb79b3f562
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.UI.ViewModels.MainPage;
+
+namespace Microsoft.CmdPal.UI.ViewModels.Messages;
+
+///
+/// Telemetry message sent when the user selects (invokes) a main-page search result.
+/// Carries only non-identifying aggregates - never the selected item's title/id or the raw
+/// query text. is the query's character count,
+/// is the zero-based rank of the selected result, and is its ranker tier.
+///
+public record TelemetrySearchResultSelectedMessage(int QueryLength, int SelectedIndex, RankTier SelectedTier);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs
new file mode 100644
index 0000000000..1d970c33de
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.CmdPal.UI.ViewModels.Messages;
+
+///
+/// Telemetry message sent when a main-page search query settles.
+/// Carries only non-identifying aggregates - never the raw query text.
+/// is the query's character count.
+///
+public record TelemetrySearchResultsMessage(int QueryLength, int ResultCount, bool NoResults, ulong LatencyMs);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs
index c1b6a48b0a..3e8d64bef8 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs
@@ -7,6 +7,25 @@ using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
+///
+/// Per-provider relevance nudge applied to main/root page search results. The value is a
+/// within-tier bonus only: it can reorder items that already share a relevance tier but can
+/// never move an item across a tier boundary. The underlying integer values are the sign of
+/// the bonus (Lower subtracts, Higher adds), and is 0 so a missing or
+/// legacy setting deserializes to the neutral default.
+///
+public enum ProviderSearchWeight
+{
+ /// De-prioritize this provider's results within their tier.
+ Lower = -1,
+
+ /// Default. No provider nudge.
+ Normal = 0,
+
+ /// Prioritize this provider's results within their tier.
+ Higher = 1,
+}
+
public record ProviderSettings
{
// List of built-in fallbacks that should not have global results enabled by default
@@ -18,6 +37,12 @@ public record ProviderSettings
public bool IsEnabled { get; init; } = true;
+ ///
+ /// Per-provider within-tier ranking nudge for main-page search. Defaults to
+ /// ; missing/legacy values deserialize to Normal.
+ ///
+ public ProviderSearchWeight SearchWeight { get; init; } = ProviderSearchWeight.Normal;
+
private ImmutableDictionary? _fallbackCommands
= ImmutableDictionary.Empty;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs
index e6338b5646..831e63b054 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs
@@ -134,6 +134,41 @@ public partial class ProviderSettingsViewModel : ObservableObject
}
}
+ ///
+ /// Per-provider search weight surfaced as a 0/1/2 ComboBox index (Lower / Normal /
+ /// Higher) so it can bind to ComboBox.SelectedIndex in the same style as the
+ /// other per-provider options. Persists to .
+ ///
+ public int SearchWeightIndex
+ {
+ get => _providerSettings.SearchWeight switch
+ {
+ ProviderSearchWeight.Lower => 0,
+ ProviderSearchWeight.Higher => 2,
+ _ => 1,
+ };
+ set
+ {
+ var newWeight = value switch
+ {
+ 0 => ProviderSearchWeight.Lower,
+ 2 => ProviderSearchWeight.Higher,
+ _ => ProviderSearchWeight.Normal,
+ };
+
+ if (newWeight != _providerSettings.SearchWeight)
+ {
+ var newSettings = _providerSettings with { SearchWeight = newWeight };
+ _settingsService.UpdateSettings(s => s with
+ {
+ ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings),
+ });
+ _providerSettings = newSettings;
+ OnPropertyChanged(nameof(SearchWeightIndex));
+ }
+ }
+ }
+
///
/// Gets a value indicating whether returns true if we have a settings page
/// that's initialized, or we are still working on initializing that
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs
index 51e714a526..dd7a89ec93 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs
@@ -3,61 +3,156 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
+using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels;
public record RecentCommandsManager : IRecentCommandsManager
{
+ // Recency half-life: a command's recency contribution halves every this many days. Three
+ // days balances an interactive launcher's usage rhythm - commands touched in the last few
+ // days still feel "recent", while month-old one-offs decay toward zero and stop crowding
+ // out newer habits.
+ internal const double HalfLifeDays = 3.0;
+
+ // Baseline points for a just-used command (recency == 1) before the frequency term. Kept
+ // near the previous top recency bucket so the frecency signal keeps roughly the same
+ // influence in the ranker's within-tier math (MainListRanker.FrecencyScale == 1.0).
+ internal const double BaseWeight = 10.0;
+
+ // Points added per unit of log2(uses + 1), scaled by recency. log() keeps heavy usage
+ // helpful without letting it dominate recency (uses 1 -> +10, 7 -> +30, 31 -> +50).
+ internal const double FrequencyWeight = 10.0;
+
+ // Upper bound on the returned weight. Holds the signal in the ~0..70 range the previous
+ // implementation produced, so tier ordering is unaffected (the tier always dominates and
+ // frecency only reorders within a tier).
+ internal const double MaxWeight = 70.0;
+
+ // Retain a few hundred commands. Large enough to remember habitual commands across weeks
+ // of use, small enough that the persisted JSON stays tiny (tens of KB). The old cap of 50
+ // evicted still-useful history within a single busy session on active machines.
+ internal const int MaxHistoryEntries = 500;
+
+ // Defensive fallback for any history item that carries a default (missing) LastUsed - for
+ // example a value constructed without a timestamp, or state written by a build that predates
+ // the LastUsed field. Such an item is treated as used one day ago: recent enough that it
+ // still ranks, mild enough that a single fresh use outranks it, and uniform so a group of
+ // them falls back to Uses (frequency) ordering instead of collapsing to all-equal or zero.
+ // In practice this rarely fires: earlier builds never actually persisted history (the
+ // internal History property was dropped by the serializer, see [JsonInclude] below), so
+ // upgrading users start from an empty store rather than one full of timestamp-less items.
+ internal static readonly TimeSpan LegacyBackdate = TimeSpan.FromDays(1);
+
private ImmutableList? _history = ImmutableList.Empty;
+ // Cached commandId -> entry lookup over History, rebuilt lazily whenever History is
+ // (re)assigned and never persisted. ScoreTopLevelItem calls GetCommandHistoryWeight for
+ // every candidate item on every keystroke, and History can hold up to MaxHistoryEntries
+ // (500) entries, so a plain linear scan would be O(items x history) per keystroke. The
+ // dictionary keeps each lookup O(1) so the hot path stays cheap as the store grows.
+ [JsonIgnore]
+ private Dictionary? _index;
+
+ // Persisted so recent-command frecency (including the LastUsed timestamps) survives a
+ // restart. [JsonInclude] is required because the property is internal; without it the
+ // source-generated serializer emits an empty object and history is silently dropped.
+ // Old persisted state (an empty object) still deserializes fine - History stays empty.
+ [JsonInclude]
internal ImmutableList History
{
get => _history ?? ImmutableList.Empty;
- init => _history = value;
+ init
+ {
+ _history = value;
+
+ // Invalidate the cached lookup so it is rebuilt from the new list on next use.
+ // A record 'with' copy carries over the old field, so this reset is what keeps
+ // the index from going stale after History changes.
+ _index = null;
+ }
+ }
+
+ private Dictionary Index
+ {
+ get
+ {
+ if (_index is null)
+ {
+ // Ordinal to match the string '==' comparison the previous linear scan used.
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var item in History)
+ {
+ // History is most-recent-first and command ids are unique in it, but keep
+ // the first (most recent) occurrence if a duplicate ever slips in so the
+ // lookup matches the old FirstOrDefault behavior.
+ map.TryAdd(item.CommandId, item);
+ }
+
+ _index = map;
+ }
+
+ return _index;
+ }
}
public RecentCommandsManager()
{
}
+ ///
+ /// Builds the lazy command-id lookup now, on the calling thread. The build isn't thread-safe,
+ /// so call this once before scoring items in parallel; the reads afterward are safe.
+ ///
+ public void PrewarmIndex() => _ = Index;
+
public int GetCommandHistoryWeight(string commandId)
+ => GetCommandHistoryWeight(commandId, DateTimeOffset.UtcNow);
+
+ ///
+ /// Computes the time-decayed frecency weight for a command relative to .
+ /// Recency uses an exponential half-life decay and frequency uses log(uses); the two are
+ /// combined so recency leads while frequency amplifies. The parameterless overload uses the
+ /// current time; this one lets a batch pin a single snapshot.
+ ///
+ public int GetCommandHistoryWeight(string commandId, DateTimeOffset now)
{
- var entry = History
- .Index()
- .Where(item => item.Item.CommandId == commandId)
- .FirstOrDefault();
-
- // These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
- // match after one use.
- // Usually it has a weight of 84, compared to 109 for the VS cmd prompt
- if (entry.Item is not null)
+ if (!Index.TryGetValue(commandId, out var entry))
{
- var index = entry.Index;
-
- // First, add some weight based on how early in the list this appears
- var bucket = index switch
- {
- _ when index <= 2 => 35,
- _ when index <= 10 => 25,
- _ when index <= 15 => 15,
- _ when index <= 35 => 10,
- _ => 5,
- };
-
- // Then, add weight for how often this is used, but cap the weight from usage.
- var uses = Math.Min(entry.Item.Uses * 5, 35);
-
- return bucket + uses;
+ return 0;
}
- return 0;
+ // Migrate items with a default (missing) timestamp to a mild backdate so they degrade
+ // to Uses-ordering instead of appearing brand-new or invisible. See LegacyBackdate.
+ var lastUsed = entry.LastUsed == default ? now - LegacyBackdate : entry.LastUsed;
+
+ // Clamp age at zero so a slightly-future timestamp (e.g. clock skew) can't amplify.
+ var ageDays = Math.Max(0.0, (now - lastUsed).TotalDays);
+
+ // Exponential time decay: recency in (0, 1], halving every HalfLifeDays.
+ var recency = Math.Pow(2.0, -ageDays / HalfLifeDays);
+
+ // Frequency via log2 so heavy usage helps but can't outrun recency.
+ var frequency = Math.Log(entry.Uses + 1, 2);
+
+ var weight = recency * (BaseWeight + (FrequencyWeight * frequency));
+
+ return (int)Math.Round(Math.Clamp(weight, 0.0, MaxWeight));
}
///
/// Returns a new RecentCommandsManager with the given command added/promoted in history.
- /// Pure function — does not mutate this instance.
+ /// Pure function - does not mutate this instance.
///
public RecentCommandsManager WithHistoryItem(string commandId)
+ => WithHistoryItem(commandId, DateTimeOffset.UtcNow);
+
+ ///
+ /// Records a use of at the explicit time .
+ /// The public overload uses the current time; this overload lets tests inject
+ /// strictly-increasing timestamps so time-decay behavior is deterministic.
+ ///
+ internal RecentCommandsManager WithHistoryItem(string commandId, DateTimeOffset now)
{
var existing = History.FirstOrDefault(item => item.CommandId == commandId);
ImmutableList newHistory;
@@ -65,18 +160,18 @@ public record RecentCommandsManager : IRecentCommandsManager
if (existing is not null)
{
newHistory = History.Remove(existing);
- var updated = existing with { Uses = existing.Uses + 1 };
+ var updated = existing with { Uses = existing.Uses + 1, LastUsed = now };
newHistory = newHistory.Insert(0, updated);
}
else
{
- var newItem = new HistoryItem { CommandId = commandId, Uses = 1 };
+ var newItem = new HistoryItem { CommandId = commandId, Uses = 1, LastUsed = now };
newHistory = History.Insert(0, newItem);
}
- if (newHistory.Count > 50)
+ if (newHistory.Count > MaxHistoryEntries)
{
- newHistory = newHistory.RemoveRange(50, newHistory.Count - 50);
+ newHistory = newHistory.RemoveRange(MaxHistoryEntries, newHistory.Count - MaxHistoryEntries);
}
return this with { History = newHistory };
@@ -87,5 +182,17 @@ public interface IRecentCommandsManager
{
int GetCommandHistoryWeight(string commandId);
+ ///
+ /// Frecency weight for a command at an explicit evaluation time, so a whole batch can be
+ /// scored against one snapshot instead of a moving clock.
+ ///
+ int GetCommandHistoryWeight(string commandId, DateTimeOffset now);
+
RecentCommandsManager WithHistoryItem(string commandId);
+
+ ///
+ /// Builds any lazy internal state on the calling thread. Call it once before scoring items in
+ /// parallel, since the build itself isn't thread-safe.
+ ///
+ void PrewarmIndex();
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs
index 877d01aa34..bc498e8554 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs
@@ -433,12 +433,16 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
// RPC to check type
if (model is IFallbackCommandItem fallback)
{
- var wasEmpty = string.IsNullOrEmpty(Title);
+ var oldTitle = Title;
// RPC for method
fallback.FallbackHandler.UpdateQuery(newQuery);
- var isEmpty = string.IsNullOrEmpty(Title);
- return wasEmpty != isEmpty;
+ var newTitle = Title;
+
+ // Report any title change, not just an empty <-> non-empty flip: the render path
+ // re-scores fallbacks off this signal, so a change like "server01" -> "server02"
+ // must still trigger a refresh or the fallback keeps its stale score and position.
+ return !string.Equals(oldTitle, newTitle, StringComparison.Ordinal);
}
return false;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs
new file mode 100644
index 0000000000..62bc909dce
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Tracing;
+using Microsoft.PowerToys.Telemetry;
+using Microsoft.PowerToys.Telemetry.Events;
+
+namespace Microsoft.CmdPal.UI.Events;
+
+///
+/// Tracks which main-page search result the user selected (invoked).
+/// Purpose: measure whether the ranking overhaul surfaces the wanted result near the top.
+/// Privacy: only non-identifying aggregates are captured. The selected item's title,
+/// subtitle, id, and the raw query text are never logged - only the query character
+/// length, the zero-based rank (index) of the selected result, and its ranker tier name.
+/// Emission goes through , which respects the existing
+/// PowerToys data-diagnostics consent gate.
+///
+[EventData]
+[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
+public class CmdPalSearchResultSelected : EventBase, IEvent
+{
+ ///
+ /// Gets or sets the character length of the query at selection (never the query text).
+ ///
+ public int QueryLength { get; set; }
+
+ ///
+ /// Gets or sets the zero-based rank of the selected result within the visible results.
+ ///
+ public int SelectedIndex { get; set; }
+
+ ///
+ /// Gets or sets the ranker tier name of the selected result (e.g. "ExactTitle").
+ /// This is a fixed, non-identifying enum name, not user content.
+ ///
+ public string SelectedTier { get; set; }
+
+ public CmdPalSearchResultSelected(int queryLength, int selectedIndex, string selectedTier)
+ {
+ EventName = "CmdPal_SearchResultSelected";
+ QueryLength = queryLength;
+ SelectedIndex = selectedIndex;
+ SelectedTier = selectedTier;
+ }
+
+ public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs
new file mode 100644
index 0000000000..5b0e1cbf26
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Tracing;
+using Microsoft.PowerToys.Telemetry;
+using Microsoft.PowerToys.Telemetry.Events;
+
+namespace Microsoft.CmdPal.UI.Events;
+
+///
+/// Tracks the outcome of a settled main-page search query.
+/// Purpose: measure search relevance and perceived speed for the ranking overhaul.
+/// Privacy: only non-identifying aggregates are captured. The raw query text is never
+/// logged - only its character length. No titles, subtitles, paths, or app names are
+/// captured. Emission goes through , which respects the
+/// existing PowerToys data-diagnostics consent gate.
+///
+[EventData]
+[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
+public class CmdPalSearchResults : EventBase, IEvent
+{
+ ///
+ /// Gets or sets the character length of the query (never the query text itself).
+ ///
+ public int QueryLength { get; set; }
+
+ ///
+ /// Gets or sets the number of deterministic first-paint results (commands and apps)
+ /// produced for the query.
+ ///
+ public int ResultCount { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the query produced no deterministic results.
+ ///
+ public bool NoResults { get; set; }
+
+ ///
+ /// Gets or sets the time in milliseconds to produce the deterministic first-paint results.
+ ///
+ public ulong LatencyMs { get; set; }
+
+ public CmdPalSearchResults(int queryLength, int resultCount, bool noResults, ulong latencyMs)
+ {
+ EventName = "CmdPal_SearchResults";
+ QueryLength = queryLength;
+ ResultCount = resultCount;
+ NoResults = noResults;
+ LatencyMs = latencyMs;
+ }
+
+ public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs
index 84fab2734b..267b8d2a33 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs
@@ -21,7 +21,9 @@ internal sealed class TelemetryForwarder :
IRecipient,
IRecipient,
IRecipient,
- IRecipient
+ IRecipient,
+ IRecipient,
+ IRecipient
{
public TelemetryForwarder()
{
@@ -29,6 +31,8 @@ internal sealed class TelemetryForwarder :
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
}
// Message handlers for telemetry events from core layer
@@ -68,6 +72,23 @@ internal sealed class TelemetryForwarder :
message.EndBands));
}
+ public void Receive(TelemetrySearchResultsMessage message)
+ {
+ PowerToysTelemetry.Log.WriteEvent(new CmdPalSearchResults(
+ message.QueryLength,
+ message.ResultCount,
+ message.NoResults,
+ message.LatencyMs));
+ }
+
+ public void Receive(TelemetrySearchResultSelectedMessage message)
+ {
+ PowerToysTelemetry.Log.WriteEvent(new CmdPalSearchResultSelected(
+ message.QueryLength,
+ message.SelectedIndex,
+ message.SelectedTier.ToString()));
+ }
+
// Static method for logging session duration from UI layer
public static void LogSessionDuration(
ulong durationMs,
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml
index 785abaa86e..15cbb4beb1 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml
@@ -79,6 +79,17 @@
Value="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
+
+
+
+
+
+
+
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
index 80755b0045..e6b7d5a2fa 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw
@@ -348,6 +348,24 @@ Right-click to remove the key combination, thereby deactivating the shortcut.
Choose when the alias runs. Direct runs as soon as you type the alias. Indirect runs after a trailing space.
+
+ Search weight
+
+
+ Nudge this provider's results up or down among equally-relevant matches.
+
+
+ Search weight
+
+
+ Lower
+
+
+ Normal
+
+
+ Higher
+
Built-in
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/EarlyFrameRelevanceTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/EarlyFrameRelevanceTests.cs
new file mode 100644
index 0000000000..ee74b637b4
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/EarlyFrameRelevanceTests.cs
@@ -0,0 +1,315 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CmdPal.Common.Helpers;
+using Microsoft.CmdPal.Common.Text;
+using Microsoft.CmdPal.UI.ViewModels.Commands;
+using Microsoft.CmdPal.UI.ViewModels.MainPage;
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
+
+[TestClass]
+public sealed partial class EarlyFrameRelevanceTests
+{
+ public TestContext TestContext { get; set; } = null!;
+
+ private sealed partial class CatalogItem : ListItem, IPrecomputedListItem
+ {
+ private FuzzyTargetCache _titleCache;
+ private FuzzyTargetCache _subtitleCache;
+
+ public CatalogItem(string title, string subtitle, string id)
+ : base(new NoOpCommand() { Id = id })
+ {
+ Title = title;
+ Subtitle = subtitle;
+ Id = id;
+ }
+
+ public string Id { get; }
+
+ public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title);
+
+ public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle);
+ }
+
+ private static IPrecomputedFuzzyMatcher CreateMatcher() => new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
+
+ private static ScoringFunction BuildScoringFunction(IRecentCommandsManager history, IPrecomputedFuzzyMatcher matcher)
+ => (in FuzzyQuery query, IListItem item) => MainListPage.ScoreTopLevelItem(query, item, history, matcher, null);
+
+ private static RoScored[] Score(IReadOnlyList apps, string rawQuery, IRecentCommandsManager history, IPrecomputedFuzzyMatcher matcher)
+ {
+ var query = matcher.PrecomputeQuery(rawQuery);
+ var fn = BuildScoringFunction(history, matcher);
+ return InternalListHelpers.FilterListWithScores(apps.Cast().ToArray(), query, fn);
+ }
+
+ private static RecentCommandsManager SeedUses(RecentCommandsManager history, string commandId, int uses)
+ {
+ for (var i = 0; i < uses; i++)
+ {
+ history = history.WithHistoryItem(commandId);
+ }
+
+ return history;
+ }
+
+ // Every app matches "x" only at the Fuzzy tier.
+ private static CatalogItem[] BuildFuzzyOnlyCatalogForX() =>
+ [
+ new CatalogItem("Galaxy Store", "Shop for apps", "app.galaxy"),
+ new CatalogItem("Nexus Mods", "Manage game mods", "app.nexus"),
+ new CatalogItem("Toolbox Companion", "Developer tools", "app.toolbox"),
+ new CatalogItem("Max Cleaner", "Free up disk space", "app.max"),
+ new CatalogItem("Voxel Editor", "Edit voxel art", "app.voxel"),
+ ];
+
+ // Apps spanning multiple tiers for "c": some titles start with it, some have a non-leading word
+ // that does, and some only contain it mid-word.
+ private static CatalogItem[] BuildMixedCatalogForC() =>
+ [
+ new CatalogItem("Calculator", "Perform calculations", "app.calc"),
+ new CatalogItem("Calendar", "View your schedule", "app.cal"),
+ new CatalogItem("Visual Studio Code", "Code editor", "app.vscode"),
+ new CatalogItem("Windows Camera", "Take photos", "app.camera"),
+ new CatalogItem("Microsoft Edge", "Browse the web", "app.edge"),
+ new CatalogItem("Office Hub", "Productivity apps", "app.office"),
+ new CatalogItem("Discord", "Chat with friends", "app.discord"),
+ ];
+
+ ///
+ /// The mechanism, locked as a test: when a 1-char query only matches mid-word, every result is
+ /// Fuzzy, so seeding frecency on any one of them floats it straight to rank 1.
+ ///
+ [TestMethod]
+ public void ShortQuery_FrecencyFloatsWeakFuzzyMatchToTop()
+ {
+ var matcher = CreateMatcher();
+ var apps = BuildFuzzyOnlyCatalogForX();
+
+ // With no history, some app is at rank 1 purely on lexical quality.
+ var noHistory = Score(apps, "x", new RecentCommandsManager(), matcher);
+ Assert.IsTrue(noHistory.Length > 0, "The fuzzy-only catalog must still match 'x'.");
+ foreach (var s in noHistory)
+ {
+ Assert.AreEqual(
+ RankTier.Fuzzy,
+ MainListRanker.TierOf(s.Score),
+ $"Every 'x' match must be Fuzzy tier; '{s.Item.Title}' was {MainListRanker.TierOf(s.Score)}.");
+ }
+
+ // Seed heavy frecency on an app that was NOT already at the top, and confirm it floats up.
+ var seededId = noHistory[^1].Item is CatalogItem last ? last.Id : throw new InvalidOperationException();
+ var seededTitle = noHistory[^1].Item.Title;
+ var history = SeedUses(new RecentCommandsManager(), seededId, 40);
+
+ var withHistory = Score(apps, "x", history, matcher);
+
+ TestContext.WriteLine($"no-history rank1='{noHistory[0].Item.Title}', seeded '{seededTitle}' -> rank1='{withHistory[0].Item.Title}'.");
+
+ Assert.AreEqual(RankTier.Fuzzy, MainListRanker.TierOf(withHistory[0].Score), "The floated rank-1 item is still only a Fuzzy match.");
+ Assert.AreEqual(seededTitle, withHistory[0].Item.Title, "Frecency should float the seeded weak match to rank 1 within the Fuzzy tier.");
+ }
+
+ ///
+ /// A short query filters every fuzzy-only app match.
+ ///
+ [TestMethod]
+ public void ShortQuery_Filter_RemovesEveryFuzzyMatch()
+ {
+ var matcher = CreateMatcher();
+ var apps = BuildFuzzyOnlyCatalogForX();
+
+ var seededId = apps[0].Id;
+ var history = SeedUses(new RecentCommandsManager(), seededId, 40);
+
+ var scored = Score(apps, "x", history, matcher);
+ Assert.IsTrue(scored.Length > 0, "Precondition: the ungated result surfaces weak fuzzy matches.");
+
+ var filtered = MainListPage.FilterAppsForShortQueries(scored, queryLength: 1);
+
+ Assert.IsNotNull(filtered);
+ Assert.AreEqual(0, filtered!.Count, "A one-character query should filter fuzzy-only app matches.");
+ }
+
+ ///
+ /// The filter keeps high-confidence matches and removes fuzzy matches.
+ ///
+ [TestMethod]
+ public void ShortQuery_Filter_KeepsHighConfidenceMatches()
+ {
+ var matcher = CreateMatcher();
+ var apps = BuildMixedCatalogForC();
+
+ var scored = Score(apps, "c", new RecentCommandsManager(), matcher);
+
+ var fuzzyCount = scored.Count(s => MainListRanker.TierOf(s.Score) == RankTier.Fuzzy);
+ var confidentCount = scored.Count(s => (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary);
+
+ Assert.IsTrue(fuzzyCount > 0, "Precondition: the mixed catalog produces some fuzzy-tail matches for 'c'.");
+ Assert.IsTrue(confidentCount > 0, "Precondition: the mixed catalog produces some confident matches for 'c'.");
+
+ var filtered = MainListPage.FilterAppsForShortQueries(scored, queryLength: 1);
+ Assert.IsNotNull(filtered);
+
+ Assert.AreEqual(confidentCount, filtered!.Count, "Only word-boundary or stronger matches should remain.");
+ foreach (var s in filtered)
+ {
+ Assert.IsTrue(
+ (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary,
+ $"Filtered item '{s.Item.Title}' must be word-boundary tier or higher, was {MainListRanker.TierOf(s.Score)}.");
+ }
+
+ // Filtering preserves the scored order.
+ for (var i = 0; i < filtered.Count; i++)
+ {
+ Assert.AreSame(scored[i].Item, filtered[i].Item, $"Filtered item at index {i} must keep its scored position.");
+ }
+ }
+
+ ///
+ /// Queries longer than two characters return the original array.
+ ///
+ [TestMethod]
+ public void LongerQuery_Filter_ReturnsInputUnchanged()
+ {
+ var matcher = CreateMatcher();
+ var apps = BuildMixedCatalogForC();
+
+ foreach (var raw in new[] { "cal", "calc", "code" })
+ {
+ var scored = Score(apps, raw, new RecentCommandsManager(), matcher);
+ var filtered = MainListPage.FilterAppsForShortQueries(scored, raw.Length);
+
+ Assert.AreSame(scored, filtered, $"A {raw.Length}-character query ('{raw}') should return the original array.");
+ }
+ }
+
+ ///
+ /// The filter applies to query lengths one and two.
+ ///
+ [TestMethod]
+ public void Filter_LengthBoundary_AppliesToOneAndTwo_NotThree()
+ {
+ var matcher = CreateMatcher();
+ var apps = BuildFuzzyOnlyCatalogForX();
+ var scored = Score(apps, "x", new RecentCommandsManager(), matcher);
+ Assert.IsTrue(scored.Length > 0, "Precondition: 'x' matches fuzzily.");
+
+ Assert.AreEqual(0, MainListPage.FilterAppsForShortQueries(scored, 1)!.Count, "Length 1 should be filtered.");
+ Assert.AreEqual(0, MainListPage.FilterAppsForShortQueries(scored, 2)!.Count, "Length 2 should be filtered.");
+ Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, 3), "Length 3 should not be filtered.");
+ }
+
+ ///
+ /// Null, empty, and default-view inputs are unchanged.
+ ///
+ [TestMethod]
+ public void Filter_NullEmptyAndZeroLength_AreNoOps()
+ {
+ Assert.IsNull(MainListPage.FilterAppsForShortQueries(null, 1));
+
+ var empty = Array.Empty>();
+ Assert.AreSame(empty, MainListPage.FilterAppsForShortQueries(empty, 1));
+
+ var matcher = CreateMatcher();
+ var scored = Score(BuildFuzzyOnlyCatalogForX(), "x", new RecentCommandsManager(), matcher);
+ Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, 0), "A zero-length query should return the original array.");
+ }
+
+ ///
+ /// Counts the leading entries at or above the requested tier.
+ ///
+ [TestMethod]
+ public void GetHighConfidenceAppsCount_CountsLeadingHighTierEntries()
+ {
+ RoScored Make(RankTier tier, int within)
+ => new(new CatalogItem($"{tier}", string.Empty, $"{tier}.{within}"), MainListRanker.Pack(tier, within));
+
+ // Sorted descending by packed score: Exact(5) > Prefix(4) > WordBoundary(3) > Fuzzy(2) > Fallback(1).
+ var scored = new[]
+ {
+ Make(RankTier.ExactTitle, 100),
+ Make(RankTier.Prefix, 50),
+ Make(RankTier.AcronymWordBoundary, 20),
+ Make(RankTier.Fuzzy, 9000),
+ Make(RankTier.FallbackFloor, 5),
+ };
+
+ Assert.AreEqual(5, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.FallbackFloor));
+ Assert.AreEqual(4, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.Fuzzy));
+ Assert.AreEqual(3, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.AcronymWordBoundary));
+ Assert.AreEqual(2, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.Prefix));
+ Assert.AreEqual(1, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.ExactTitle));
+ Assert.AreEqual(0, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.AliasExact));
+ }
+
+ ///
+ /// Uses the query length published with the scored array.
+ ///
+ [TestMethod]
+ public void Filter_UsesSuppliedPublishedLength()
+ {
+ RoScored Fuzzy(int within)
+ => new(new CatalogItem($"fuzzy.{within}", string.Empty, $"fuzzy.{within}"), MainListRanker.Pack(RankTier.Fuzzy, within));
+
+ var scored = new[] { Fuzzy(30), Fuzzy(20), Fuzzy(10) };
+
+ var filteredByPublished = MainListPage.FilterAppsForShortQueries(scored, queryLength: 2);
+ Assert.IsNotNull(filteredByPublished);
+ Assert.AreEqual(0, filteredByPublished!.Count, "The published short length should filter fuzzy matches.");
+
+ Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, queryLength: 5), "A longer published length should return the original array.");
+ }
+
+ ///
+ /// Telemetry counts only visible apps after filtering.
+ ///
+ [TestMethod]
+ public void GetVisibleAppCount_ShortQuery_CountsOnlyFilteredApps()
+ {
+ var matcher = CreateMatcher();
+ var apps = BuildMixedCatalogForC();
+ var scored = Score(apps, "c", new RecentCommandsManager(), matcher);
+
+ var full = scored.Length;
+ var confident = scored.Count(s => (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary);
+ Assert.IsTrue(confident < full, "Precondition: fuzzy matches make the filtered count smaller.");
+
+ const int NoCap = 1000;
+
+ Assert.AreEqual(confident, MainListPage.GetVisibleAppCount(scored, queryLength: 1, appResultLimit: NoCap));
+ Assert.AreEqual(confident, MainListPage.GetVisibleAppCount(scored, queryLength: 2, appResultLimit: NoCap));
+
+ Assert.AreEqual(full, MainListPage.GetVisibleAppCount(scored, queryLength: 3, appResultLimit: NoCap));
+ Assert.AreEqual(full, MainListPage.GetVisibleAppCount(scored, queryLength: 0, appResultLimit: NoCap), "A zero-length query should count the full set.");
+ }
+
+ ///
+ /// The count respects the app limit and handles empty input.
+ ///
+ [TestMethod]
+ public void GetVisibleAppCount_RespectsCap_AndEmptyInput()
+ {
+ var matcher = CreateMatcher();
+
+ var fuzzyOnly = Score(BuildFuzzyOnlyCatalogForX(), "x", new RecentCommandsManager(), matcher);
+ Assert.IsTrue(fuzzyOnly.Length > 0, "Precondition: 'x' matches fuzzily.");
+ Assert.AreEqual(0, MainListPage.GetVisibleAppCount(fuzzyOnly, queryLength: 1, appResultLimit: 1000));
+
+ var mixed = Score(BuildMixedCatalogForC(), "c", new RecentCommandsManager(), matcher);
+ Assert.IsTrue(mixed.Length > 2, "Precondition: the mixed catalog has more than two matches so the cap bites.");
+ Assert.AreEqual(2, MainListPage.GetVisibleAppCount(mixed, queryLength: 3, appResultLimit: 2), "The app result limit should cap the count.");
+
+ Assert.AreEqual(0, MainListPage.GetVisibleAppCount(null, 1, 1000));
+ Assert.AreEqual(0, MainListPage.GetVisibleAppCount(Array.Empty>(), 1, 1000));
+ }
+}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/FastFirstPaintTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/FastFirstPaintTests.cs
new file mode 100644
index 0000000000..4be0ea0a8c
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/FastFirstPaintTests.cs
@@ -0,0 +1,215 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.CmdPal.Common.Helpers;
+using Microsoft.CmdPal.Common.Text;
+using Microsoft.CmdPal.UI.ViewModels.Commands;
+using Microsoft.CmdPal.UI.ViewModels.MainPage;
+using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
+
+///
+/// Guardrail tests for the "fast first paint" behavior of the main/root page.
+///
+/// The typing path renders deterministic in-proc results (top-level commands + installed
+/// apps) immediately and folds in slow, out-of-proc fallback contributions asynchronously
+/// as they resolve. These tests pin the two invariants that make that safe:
+/// 1. Deterministic results are produced without any fallback contribution present, so
+/// first paint never waits on a slow source.
+/// 2. Late-arriving fallbacks are merged with a fresh score from the same ranker, always
+/// at the FallbackFloor tier, so they can never leapfrog deterministic matches, and a
+/// superseding query's snapshot replaces any stale one.
+///
+[TestClass]
+public sealed partial class FastFirstPaintTests
+{
+ private static readonly Separator _resultsSeparator = new("Results");
+ private static readonly Separator _fallbacksSeparator = new("Fallbacks");
+
+ // A list item whose Title can be mutated to simulate an extension resolving a dynamic
+ // fallback title asynchronously, after first paint.
+ private sealed partial class MutableListItem : IListItem
+ {
+ public string Title { get; set; } = string.Empty;
+
+ public string Subtitle { get; set; } = string.Empty;
+
+ public ICommand Command => new NoOpCommand();
+
+ public IDetails? Details => null;
+
+ public IIconInfo? Icon => null;
+
+ public string Section => string.Empty;
+
+ public ITag[] Tags => [];
+
+ public string TextToSuggest => string.Empty;
+
+ public IContextItem[] MoreCommands => [];
+
+#pragma warning disable CS0067 // The event is never used
+ public event TypedEventHandler