CmdPal: Fallback ranking and global results (#43549)

> [!IMPORTANT]
> For extension developers, this release includes a new required `string
Id` property for `FallbackCommandItem`. While your existing extensions
will continue to work, without this `Id` being set, your fallbacks will
not display and will not be rankable.
> Before this is released, you will want to prepare your extension
fallbacks.
> 
> As an example, we are naming our built-in extensions as:
> - Calculator extension provider Id:
`com.microsoft.cmdpal.builtin.calculator`
> - Calculator extension fallback:
`com.microsoft.cmdpal.builtin.calculator.fallback`
> 
> While the content of the Id isn't important, what is important is that
it is unique to your extension and fallback to avoid conflicting with
other extensions.

Now the good stuff:

## What the heck does it do!?

### The backstory

In PowerToys 0.95, we released performance improvements to Command
Palette. One of the many ways we improved its speed is by no longer
ranking fallback commands with other "top level" commands. Instead, all
fallbacks would surface at the bottom of the results and be listed in
the order they were registered with Command Palette. But this was only a
temporary solution until the work included in this pull request was
ready.

In reality, not all fallbacks were treated equally. We marked the
calculator and run fallbacks as "special." Special fallbacks **were**
ranked like top-level commands and allowed to surface to the top of the
results.

### The new "hotness"

This PR brings the power of fallback management back to the people. In
the Command Palette settings, you, dear user, can specify what order you
want fallbacks to display in at the bottom of the results. This keeps
those fallbacks unranked by Command Palette but displays them in an
order that makes sense for you. But keep in mind, these will still live
at the bottom of search results.

But alas, we have also heard your cries that you'd like _some_ fallbacks
to be ranked by Command Palette and surface to the top of the results.
So, this PR allows you to mark any fallback as "special" by choosing to
include them in the global results. Special (Global) fallbacks are
treated like "top level" commands and appear in the search result based
on their title & description.

### Screenshots/video

<img width="1005" height="611" alt="image"
src="https://github.com/user-attachments/assets/8ba5d861-f887-47ed-8552-ba78937322d2"
/>

<img width="1501" height="973" alt="image"
src="https://github.com/user-attachments/assets/9edb7675-8084-4f14-8bdc-72d7d06d500e"
/>

<img width="706" height="744" alt="image"
src="https://github.com/user-attachments/assets/81ae0252-b87d-4172-a5ea-4d3102134baf"
/>

<img width="666" height="786" alt="image"
src="https://github.com/user-attachments/assets/acb76acf-531d-4e60-bb44-d1edeec77dce"
/>


### GitHub issue maintenance details

Closes #38312
Closes #38288
Closes #42524
Closes #41024
Closes #40351
Closes #41696
Closes #40193

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Jiří Polášek <me@jiripolasek.com>
This commit is contained in:
Michael Jolley
2025-12-22 17:08:15 -06:00
committed by GitHub
parent 8682d0f54d
commit f1e045751a
41 changed files with 738 additions and 192 deletions

View File

@@ -2,7 +2,6 @@
// 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.Core.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -27,7 +26,13 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
public override IFallbackCommandItem[] FallbackCommands() =>
[
new FallbackCommandItem(quitCommand, displayTitle: Properties.Resources.builtin_quit_subtitle) { Subtitle = Properties.Resources.builtin_quit_subtitle },
new FallbackCommandItem(
quitCommand,
Properties.Resources.builtin_quit_subtitle,
quitCommand.Id)
{
Subtitle = Properties.Resources.builtin_quit_subtitle,
},
_fallbackReloadItem,
_fallbackLogItem,
];

View File

@@ -13,8 +13,10 @@ internal sealed partial class FallbackLogItem : FallbackCommandItem
{
private readonly LogMessagesPage _logMessagesPage;
private const string _id = "com.microsoft.cmdpal.log";
public FallbackLogItem()
: base(new LogMessagesPage() { Id = "com.microsoft.cmdpal.log" }, Resources.builtin_log_subtitle)
: base(new LogMessagesPage() { Id = _id }, Resources.builtin_log_subtitle, _id)
{
_logMessagesPage = (LogMessagesPage)Command!;
Title = string.Empty;

View File

@@ -2,7 +2,6 @@
// 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 Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
@@ -11,10 +10,13 @@ internal sealed partial class FallbackReloadItem : FallbackCommandItem
{
private readonly ReloadExtensionsCommand _reloadCommand;
private const string _id = "com.microsoft.cmdpal.reload";
public FallbackReloadItem()
: base(
new ReloadExtensionsCommand() { Id = "com.microsoft.cmdpal.reload" },
Properties.Resources.builtin_reload_display_title)
new ReloadExtensionsCommand() { Id = _id },
Properties.Resources.builtin_reload_display_title,
_id)
{
_reloadCommand = (ReloadExtensionsCommand)Command!;
Title = string.Empty;

View File

@@ -17,7 +17,6 @@ using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
@@ -29,26 +28,17 @@ public partial class MainListPage : DynamicListPage,
IRecipient<ClearSearchMessage>,
IRecipient<UpdateFallbackItemsMessage>, IDisposable
{
private readonly string[] _specialFallbacks = [
"com.microsoft.cmdpal.builtin.run",
"com.microsoft.cmdpal.builtin.calculator",
"com.microsoft.cmdpal.builtin.system",
"com.microsoft.cmdpal.builtin.core",
"com.microsoft.cmdpal.builtin.websearch",
"com.microsoft.cmdpal.builtin.windowssettings",
"com.microsoft.cmdpal.builtin.datetime",
"com.microsoft.cmdpal.builtin.remotedesktop",
];
private readonly IServiceProvider _serviceProvider;
private readonly TopLevelCommandManager _tlcManager;
private readonly AliasManager _aliasManager;
private readonly SettingsModel _settings;
private readonly AppStateModel _appStateModel;
private List<Scored<IListItem>>? _filteredItems;
private List<Scored<IListItem>>? _filteredApps;
private List<Scored<IListItem>>? _fallbackItems;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
private IEnumerable<Scored<IListItem>>? _fallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
private int _appResultLimit = 10;
@@ -58,14 +48,16 @@ public partial class MainListPage : DynamicListPage,
private CancellationTokenSource? _cancellationTokenSource;
public MainListPage(IServiceProvider serviceProvider)
public MainListPage(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
{
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;
_serviceProvider = serviceProvider;
_tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!;
_settings = settings;
_aliasManager = aliasManager;
_appStateModel = appStateModel;
_tlcManager = topLevelCommandManager;
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
@@ -83,7 +75,6 @@ public partial class MainListPage : DynamicListPage,
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
var settings = _serviceProvider.GetService<SettingsModel>()!;
settings.SettingsChanged += SettingsChangedHandler;
HotReloadSettings(settings);
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
@@ -163,14 +154,29 @@ public partial class MainListPage : DynamicListPage,
{
// Either return the top-level commands (no search text), or the merged and
// filtered results.
return string.IsNullOrEmpty(SearchText)
? _tlcManager.TopLevelCommands.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)).ToArray()
: MainListPageResultFactory.Create(
if (string.IsNullOrWhiteSpace(SearchText))
{
return _tlcManager.TopLevelCommands
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
.ToArray();
}
else
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
_filteredItems,
_scoredFallbackItems?.ToList(),
validScoredFallbacks,
_filteredApps,
_fallbackItems,
validFallbacks,
_appResultLimit);
}
}
}
@@ -200,7 +206,7 @@ public partial class MainListPage : DynamicListPage,
// Handle changes to the filter text here
if (!string.IsNullOrEmpty(SearchText))
{
var aliases = _serviceProvider.GetService<AliasManager>()!;
var aliases = _aliasManager;
if (token.IsCancellationRequested)
{
@@ -236,7 +242,8 @@ public partial class MainListPage : DynamicListPage,
}
// prefilter fallbacks
var specialFallbacks = new List<TopLevelViewModel>(_specialFallbacks.Length);
var globalFallbacks = _settings.GetGlobalFallbacks();
var specialFallbacks = new List<TopLevelViewModel>(globalFallbacks.Length);
var commonFallbacks = new List<TopLevelViewModel>();
foreach (var s in commands)
@@ -246,7 +253,7 @@ public partial class MainListPage : DynamicListPage,
continue;
}
if (_specialFallbacks.Contains(s.CommandProviderId))
if (globalFallbacks.Contains(s.Id))
{
specialFallbacks.Add(s);
}
@@ -369,7 +376,7 @@ public partial class MainListPage : DynamicListPage,
}
}
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands!;
var history = _appStateModel.RecentCommands!;
Func<string, IListItem, int> scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
// Produce a list of everything that matches the current filter.
@@ -380,7 +387,7 @@ public partial class MainListPage : DynamicListPage,
return;
}
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && _specialFallbacks.Contains(s.CommandProviderId));
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
if (token.IsCancellationRequested)
{
@@ -394,8 +401,8 @@ public partial class MainListPage : DynamicListPage,
return;
}
// Defaulting scored to 1 but we'll eventually use user rankings
_fallbackItems = [.. newFallbacks.Select(f => new Scored<IListItem> { Item = f, Score = 1 })];
Func<string, IListItem, int> scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); };
_fallbackItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], SearchText, scoreFallbackItem)];
if (token.IsCancellationRequested)
{
@@ -464,9 +471,8 @@ public partial class MainListPage : DynamicListPage,
private bool ActuallyLoading()
{
var tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!;
var allApps = AllAppsCommandProvider.Page;
return allApps.IsLoading || tlcManager.IsLoading;
return allApps.IsLoading || _tlcManager.IsLoading;
}
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
@@ -558,13 +564,30 @@ public partial class MainListPage : DynamicListPage,
return (int)finalScore;
}
internal static int ScoreFallbackItem(string query, IListItem topLevelOrAppItem, string[] fallbackRanks)
{
// Default to 1 so it always shows in list.
var finalScore = 1;
if (topLevelOrAppItem is TopLevelViewModel topLevelViewModel)
{
var index = Array.IndexOf(fallbackRanks, topLevelViewModel.Id);
if (index >= 0)
{
finalScore = fallbackRanks.Length - index + 1;
}
}
return finalScore;
}
public void UpdateHistory(IListItem topLevelOrAppItem)
{
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var state = _serviceProvider.GetService<AppStateModel>()!;
var history = state.RecentCommands;
var history = _appStateModel.RecentCommands;
history.AddHistoryItem(id);
AppStateModel.SaveState(state);
AppStateModel.SaveState(_appStateModel);
}
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
@@ -596,10 +619,9 @@ public partial class MainListPage : DynamicListPage,
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
var settings = _serviceProvider.GetService<SettingsModel>();
if (settings is not null)
if (_settings is not null)
{
settings.SettingsChanged -= SettingsChangedHandler;
_settings.SettingsChanged -= SettingsChangedHandler;
}
WeakReferenceMessenger.Default.UnregisterAll(this);

View File

@@ -29,13 +29,19 @@ internal static class MainListPageResultFactory
}
int len1 = filteredItems?.Count ?? 0;
// Empty fallbacks are removed prior to this merge.
int len2 = scoredFallbackItems?.Count ?? 0;
// Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit.
int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit);
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
// Allocate the exact size of the result array.
int totalCount = len1 + len2 + len3 + GetNonEmptyFallbackItemsCount(fallbackItems);
// We'll add an extra slot for the fallbacks section header if needed.
int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0);
var result = new IListItem[totalCount];
// Three-way stable merge of already-sorted lists.
@@ -119,9 +125,15 @@ internal static class MainListPageResultFactory
}
// Append filtered fallback items. Fallback items are added post-sort so they are
// always at the end of the list and eventually ordered based on user preference.
// always at the end of the list and are sorted by user settings.
if (fallbackItems is not null)
{
// Create the fallbacks section header
if (fallbackItems.Count > 0)
{
result[writePos++] = new Separator(Properties.Resources.fallbacks);
}
for (int i = 0; i < fallbackItems.Count; i++)
{
var item = fallbackItems[i].Item;
@@ -143,7 +155,7 @@ internal static class MainListPageResultFactory
{
for (int i = 0; i < fallbackItems.Count; i++)
{
if (!string.IsNullOrEmpty(fallbackItems[i].Item.Title))
if (!string.IsNullOrWhiteSpace(fallbackItems[i].Item.Title))
{
fallbackItemsCount++;
}