Compare commits

...

65 Commits

Author SHA1 Message Date
Mike Griese
b7a034cbaa Merge remote-tracking branch 'origin/main' into dev/migrie/s/token-search-page 2025-12-09 04:41:16 -06:00
Mike Griese
7b41be3ce2 Merge remote-tracking branch 'origin/main' into dev/migrie/s/token-search-page 2025-12-02 06:34:44 -06:00
Niels Laute
f7a5169b72 Visual changes to param 2025-11-25 18:03:33 +01:00
Mike Griese
840de10fe6 spellbot strikes again 2025-11-21 12:57:55 -06:00
Mike Griese
40d18fdce8 @ me spellbot 2025-11-21 11:02:13 -06:00
Mike Griese
697736c5b5 Final PR cleanup 2025-11-21 10:47:51 -06:00
Mike Griese
1e71527cf2 doc updates 2025-11-21 07:05:48 -06:00
Mike Griese
84af471cb6 Resurrect the suggestion page 2025-11-21 06:38:08 -06:00
Mike Griese
459546efef nit 2025-11-20 16:59:19 -06:00
Mike Griese
39eb8abdff code cleanup 2025-11-20 16:45:05 -06:00
Mike Griese
0ae35029b2 works again 2025-11-20 14:44:17 -06:00
Mike Griese
6bb446b97f dedupe files 2025-11-20 11:20:51 -06:00
Mike Griese
4db823693c remove a bunch of files I don't need 2025-11-20 10:45:02 -06:00
Mike Griese
c2b6f9d289 Turns out we changed the type of parameter passed 2025-11-20 09:53:23 -06:00
Mike Griese
cdfda6c451 Merge remote-tracking branch 'origin/main' into dev/migrie/s/token-search-page 2025-11-20 05:31:13 -06:00
Mike Griese
85a1a8d832 make it work again 2025-11-20 05:27:36 -06:00
Mike Griese
ba9a4170aa Revert "there's no way that just works, right?"
This reverts commit bce5bc9ef5.
2025-11-19 12:58:22 -06:00
Mike Griese
bce5bc9ef5 there's no way that just works, right? 2025-11-16 20:55:16 -06:00
Mike Griese
5bb1dbe60a Merge branch 'main' into dev/migrie/s/token-search-page 2025-10-10 06:11:33 -05:00
Mike Griese
6b144aa533 spec updates 2025-09-22 15:56:48 -05:00
Mike Griese
12ef613d2d Focus the next param on enter 2025-09-22 08:51:27 -05:00
Mike Griese
446b0d478b one fewer focus 2025-09-15 09:37:15 -05:00
Mike Griese
12f95497a6 focuses search slightly better 2025-09-15 09:33:02 -05:00
Mike Griese
ea11b87b46 add a tag for LLM slop 2025-09-15 06:38:53 -05:00
Mike Griese
bd2dc3af05 add a photo picker 2025-09-15 06:15:00 -05:00
Mike Griese
bcc30fc85d Debug printing actions 2025-09-15 05:58:47 -05:00
Mike Griese
acc0965180 once agagin, i am asking to support actions 2025-09-14 06:07:55 -05:00
Mike Griese
70a7865c40 react as soon as the text changes 2025-09-13 21:05:52 -05:00
Mike Griese
a855905e8c more observable 2025-09-13 20:49:50 -05:00
Mike Griese
580ae744fa wire everything up for buttons 2025-09-13 06:52:51 -05:00
Mike Griese
e970c18de6 Make some things observable 2025-09-13 06:14:06 -05:00
Mike Griese
2671a9d4f9 Alright now, wasn't that fun? Let's try something else
Okay we've got the value through to the extension.
2025-09-12 16:31:35 -05:00
Mike Griese
e557e052f8 Wire it up to the UI, painfully, but it works 2025-09-12 15:09:17 -05:00
Mike Griese
89c9cf1e60 Merge remote-tracking branch 'origin/main' into dev/migrie/s/token-search-page 2025-09-12 13:06:12 -05:00
Mike Griese
8d10739bdb code that compiles 2025-09-12 13:06:08 -05:00
Mike Griese
c2ced6df6a a lot of notes here. Maybe we should write real code now 2025-09-12 10:49:42 -05:00
Mike Griese
2970fc5d83 I kinda like this? 2025-09-08 06:45:47 -05:00
Mike Griese
12b89738cf i hate this I think 2025-09-08 05:26:27 -05:00
Mike Griese
e55bc8c3d3 this feels like i'm getting somewhere 2025-09-05 11:19:50 -05:00
Mike Griese
9b54e96e1a Merge remote-tracking branch 'origin/main' into dev/migrie/s/token-search-page 2025-09-04 09:53:13 -05:00
Mike Griese
08b0f3052d spel 2025-08-15 16:25:18 -05:00
Mike Griese
3565582e77 subtle boxes 2025-08-15 16:23:09 -05:00
Mike Griese
eaa9cbf7f6 more cleanup 2025-08-15 15:52:39 -05:00
Mike Griese
962dc35f1f more notes, and more notes 2025-08-15 15:39:40 -05:00
Mike Griese
8183054603 i'm still fighting, i don't fear i've lost 2025-08-14 16:46:14 -05:00
Mike Griese
6c4171ef8a nah. I'm gonna do my own thing 2025-08-14 16:10:00 -05:00
Mike Griese
cc4fd8f8ae why does trimming & AOT this break this API? 2025-08-13 11:24:38 -05:00
Mike Griese
e7dd3acf51 Reapply "more updates"
This reverts commit a78ce3843a.
2025-08-13 08:42:48 -05:00
Mike Griese
a78ce3843a Revert "more updates"
This reverts commit b1432c7f54.
2025-08-13 08:42:11 -05:00
Mike Griese
b1432c7f54 more updates 2025-08-13 08:41:51 -05:00
Mike Griese
62c2c60654 Merge remote-tracking branch 'origin/main' into dev/migrie/f/properties-test 2025-08-12 13:22:03 -05:00
Mike Griese
c5db9d2bd6 rename; use IDictionary instead of IPropertySet 2025-08-11 06:34:15 -05:00
Mike Griese
9ecd374574 spel 2025-08-08 06:50:12 -05:00
Mike Griese
dd1a60c9f6 really VS really 2025-08-08 06:48:03 -05:00
Mike Griese
f3588e7f70 more spec cleanup 2025-08-08 06:12:51 -05:00
Mike Griese
940e71f2a8 stupid levels returning to nominal values 2025-08-08 05:57:58 -05:00
Mike Griese
36f85218e1 :shipit: 2025-08-07 17:06:55 -05:00
Mike Griese
3ed3bb6a81 this can be shipped 2025-08-07 17:00:11 -05:00
Mike Griese
906602090d warning - extreme stupid levels detected
(98%)  ■■■■■■■■■□
2025-08-07 16:09:57 -05:00
Mike Griese
73bc438042 I keep thinking it can't get any stupider and they keep bringing me back 2025-08-07 15:27:38 -05:00
Mike Griese
88111e2fbd Revert "not this"
This reverts commit 7eaa701920.
2025-08-07 14:19:11 -05:00
Mike Griese
7eaa701920 not this 2025-08-07 14:19:06 -05:00
Mike Griese
95cfdbb93d oh god oh god what have i done 2025-08-07 10:51:14 -05:00
Mike Griese
c198ceaa1e Merge remote-tracking branch 'origin/main' into dev/migrie/f/properties-test 2025-08-07 09:24:37 -05:00
Mike Griese
2990aad9fd this didn't work and no one knows why 2025-08-06 06:08:01 -05:00
52 changed files with 5067 additions and 26 deletions

View File

@@ -302,6 +302,7 @@ onefuzz
# NameInCode
leilzh
mengyuanchen
contoso
# DllName
testhost
@@ -330,6 +331,12 @@ HHH
riday
YYY
# names of characters
zwsp
# mermaid
autonumber
# GitHub issue/PR commands
azp
feedbackhub

View File

@@ -192,6 +192,7 @@ ycv
yeelam
Yuniardi
yuyoyuppe
zadjii
Zeol
Zhao
Zhaopeng
@@ -243,4 +244,3 @@ xamlstyler
Xavalon
Xbox
Youdao
zadjii

View File

@@ -189,6 +189,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -126,6 +126,10 @@ public partial class CommandViewModel : ExtensionObjectViewModel
var iconInfo = model.Icon;
Icon = new(iconInfo);
Icon.InitializeProperties();
break;
case nameof(_properties):
UpdatePropertiesFromExtension(model as IExtendedAttributesProvider);
break;
}

View File

@@ -65,6 +65,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
public bool IsMainPage { get; init; }
public bool IsTokenSearch { get; private set; }
private bool _isDynamic;
private Task? _initializeItemsTask;
@@ -608,6 +610,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
Filters?.InitializeProperties();
UpdateProperty(nameof(Filters));
if (model is IExtendedAttributesProvider haveProperties)
{
LoadExtendedAttributes(haveProperties.GetProperties().AsReadOnly());
}
FetchItems();
model.ItemsChanged += Model_ItemsChanged;
}
@@ -623,6 +630,17 @@ public partial class ListViewModel : PageViewModel, IDisposable
};
}
private void LoadExtendedAttributes(IReadOnlyDictionary<string, object> properties)
{
// Check if this is a token page
if (properties.TryGetValue("TokenSearch", out var isTokenSearchObj) &&
isTokenSearchObj is bool isTokenSearch)
{
IsTokenSearch = isTokenSearch;
UpdateProperty(nameof(IsTokenSearch));
}
}
public void LoadMoreIfNeeded()
{
var model = _model.Unsafe;

View File

@@ -0,0 +1,10 @@
// 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.ComponentModel;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
public record FocusParamMessage(ParameterValueRunViewModel Parameter);

View File

@@ -0,0 +1,10 @@
// 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.Core.Common.Messages;
public partial class GetHwndMessage
{
public nint Hwnd { get; set; } = 0;
}

View File

@@ -151,7 +151,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
Icon = new(page.Icon);
Icon.InitializeProperties();
HasSearchBox = page is IListPage;
HasSearchBox = (page is IListPage) || (page is IParametersPage);
// Let the UI know about our initial properties too.
UpdateProperty(nameof(Name));

View File

@@ -0,0 +1,616 @@
// 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.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Messages;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Core.ViewModels;
/// <summary>
/// View models for parameters. This file has both the viewmodels for all the
/// different run types, and the page view model.
/// </summary>
#pragma warning disable SA1402 // File may only contain a single type
#pragma warning disable SA1649 // File name should match first type name
/// <summary>
/// Base class for all parameter run view models. This includes both labels and
/// parameters that accept values.
/// </summary>
public abstract partial class ParameterRunViewModel : ExtensionObjectViewModel
{
private ExtensionObject<IParameterRun> _model;
internal InitializedState Initialized { get; set; } = InitializedState.Uninitialized;
protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized);
public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error);
internal ParameterRunViewModel(IParameterRun model, WeakReference<IPageContext> context)
: base(context)
{
_model = new(model);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
var model = _model.Unsafe;
if (model == null)
{
return;
}
model.PropChanged += Model_PropChanged;
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
// Override in derived classes
}
}
/// <summary>
/// View model for label runs. This is a non-interactive run that just displays
/// text.
/// </summary>
public partial class LabelRunViewModel : ParameterRunViewModel
{
private ExtensionObject<ILabelRun> _model;
public string Text { get; set; } = string.Empty;
public LabelRunViewModel(ILabelRun labelRun, WeakReference<IPageContext> context)
: base(labelRun, context)
{
_model = new(labelRun);
}
public override void InitializeProperties()
{
base.InitializeProperties();
var labelRun = _model.Unsafe;
if (labelRun == null)
{
return;
}
Text = labelRun.Text;
UpdateProperty(nameof(Text));
Initialized = InitializedState.Initialized;
}
protected override void FetchProperty(string propertyName)
{
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(ILabelRun.Text):
Text = model.Text;
break;
}
UpdateProperty(propertyName);
}
}
public partial class ParameterValueRunViewModel : ParameterRunViewModel
{
private ExtensionObject<IParameterValueRun> _model;
public string PlaceholderText { get; protected set; } = string.Empty;
public bool NeedsValue { get; protected set; }
public ParameterValueRunViewModel(IParameterValueRun valueRun, WeakReference<IPageContext> context)
: base(valueRun, context)
{
_model = new(valueRun);
}
public override void InitializeProperties()
{
base.InitializeProperties();
var valueRun = _model.Unsafe;
if (valueRun == null)
{
return;
}
PlaceholderText = valueRun.PlaceholderText;
NeedsValue = valueRun.NeedsValue;
UpdateProperty(nameof(PlaceholderText));
UpdateProperty(nameof(NeedsValue));
Initialized = InitializedState.Initialized;
}
protected override void FetchProperty(string propertyName)
{
// Don't bother with calling base class, because it is a no-op
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(IParameterValueRun.PlaceholderText):
PlaceholderText = model.PlaceholderText;
break;
case nameof(IParameterValueRun.NeedsValue):
NeedsValue = model.NeedsValue;
break;
}
UpdateProperty(propertyName);
}
}
public partial class StringParameterRunViewModel : ParameterValueRunViewModel
{
private ExtensionObject<IStringParameterRun> _model;
private string _modelText = string.Empty;
public string TextForUI { get => _modelText; set => SetTextFromUi(value); }
public StringParameterRunViewModel(IStringParameterRun stringRun, WeakReference<IPageContext> context)
: base(stringRun, context)
{
_model = new(stringRun);
}
public override void InitializeProperties()
{
base.InitializeProperties();
var stringRun = _model.Unsafe;
if (stringRun == null)
{
return;
}
_modelText = stringRun.Text;
UpdateProperty(nameof(TextForUI));
}
public void SetTextFromUi(string value)
{
if (value != _modelText)
{
_modelText = value;
_ = Task.Run(() =>
{
var stringRun = _model.Unsafe;
if (stringRun != null)
{
stringRun.Text = value;
}
});
}
}
protected override void FetchProperty(string propertyName)
{
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(IStringParameterRun.Text):
var newText = model.Text;
if (newText != _modelText)
{
_modelText = newText;
UpdateProperty(nameof(TextForUI));
}
else
{
return;
}
break;
}
// call the base class at the end, because ParameterValueRunViewModel
// will handle calling UpdateProperty for the property name
base.FetchProperty(propertyName);
}
}
public partial class CommandParameterRunViewModel : ParameterValueRunViewModel, IDisposable
{
private ExtensionObject<ICommandParameterRun> _model;
private ListViewModel? _listViewModel;
private CommandViewModel? _commandViewModel;
private AppExtensionHost _extensionHost;
public string DisplayText { get; set; } = string.Empty;
public IconInfoViewModel Icon { get; set; } = new(null);
public string ButtonLabel => !string.IsNullOrEmpty(DisplayText) ? DisplayText : string.Empty;
public string SearchBoxText
{
get => GetSearchText();
set => SetSearchText(value);
}
public CommandParameterRunViewModel(ICommandParameterRun commandRun, WeakReference<IPageContext> context, AppExtensionHost extensionHost)
: base(commandRun, context)
{
_model = new(commandRun);
_extensionHost = extensionHost;
}
public override void InitializeProperties()
{
base.InitializeProperties();
var commandRun = _model.Unsafe;
if (commandRun == null)
{
return;
}
DisplayText = commandRun.DisplayText;
Icon = new(commandRun.Icon);
if (Icon is not null)
{
Icon.InitializeProperties();
}
GetHwndMessage msg = new();
WeakReferenceMessenger.Default.Send(msg);
var command = commandRun.GetSelectValueCommand((ulong)msg.Hwnd);
if (command == null)
{
}
else if (command is IListPage list)
{
if (PageContext.TryGetTarget(out var pageContext))
{
_listViewModel = new ListViewModel(list, pageContext.Scheduler, _extensionHost);
_listViewModel.InitializeProperties();
}
}
else if (command is IInvokableCommand invokable)
{
_commandViewModel = new CommandViewModel(invokable, this.PageContext);
_commandViewModel.InitializeProperties();
}
UpdateProperty(nameof(DisplayText));
UpdateProperty(nameof(Icon));
}
protected override void FetchProperty(string propertyName)
{
var model = this._model.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(ICommandParameterRun.DisplayText):
DisplayText = model.DisplayText;
break;
case nameof(ICommandParameterRun.Icon):
Icon = new(model.Icon);
if (Icon is not null)
{
Icon.InitializeProperties();
}
break;
}
// call the base class at the end, because ParameterValueRunViewModel
// will handle calling UpdateProperty for the property name
base.FetchProperty(propertyName);
}
private string GetSearchText()
{
return _listViewModel?.SearchText ?? string.Empty;
}
private void SetSearchText(string value)
{
_listViewModel?.SearchTextBox = value;
}
[RelayCommand]
public void Invoke()
{
if (_commandViewModel == null)
{
return;
}
PerformCommandMessage m = new(this._commandViewModel.Model);
WeakReferenceMessenger.Default.Send(m);
}
public void Dispose()
{
GC.SuppressFinalize(this);
_listViewModel?.Dispose();
}
}
public partial class ParametersPageViewModel : PageViewModel, IDisposable
{
private ExtensionObject<IParametersPage> _model;
public override bool IsInitialized
{
get => base.IsInitialized; protected set
{
base.IsInitialized = value;
UpdateCommand();
}
}
public List<ParameterRunViewModel> Items { get; set; } = [];
public CommandItemViewModel Command { get; private set; }
public bool ShowCommand =>
IsInitialized &&
IsLoading == false &&
!NeedsAnyValues()
;
private readonly Lock _listLock = new();
public ParametersPageViewModel(IParametersPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
{
_model = new(model);
Command = new(new(null), PageContext);
}
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
//// Run on background thread, from InitializeAsync
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _model.Unsafe;
if (model is null)
{
return; // throw?
}
Command = new(new(model.Command), PageContext);
Command.SlowInitializeProperties();
FetchItems();
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchItems()
{
// Collect all the items into new viewmodels
Collection<ParameterRunViewModel> newViewModels = [];
try
{
var newItems = _model.Unsafe!.Parameters;
CoreLogger.LogDebug($"Fetched {newItems.Length} objects");
foreach (var item in newItems)
{
ParameterRunViewModel? itemVm = item switch
{
ILabelRun labelRun => new LabelRunViewModel(labelRun, PageContext),
IStringParameterRun stringRun => new StringParameterRunViewModel(stringRun, PageContext),
ICommandParameterRun commandRun => new CommandParameterRunViewModel(commandRun, PageContext, this.ExtensionHost),
_ => null,
};
var t = itemVm?.ToString() ?? "unknown";
CoreLogger.LogDebug($"Parameter item was a {t}");
if (itemVm != null)
{
itemVm.InitializeProperties();
newViewModels.Add(itemVm);
itemVm.PropertyChanged += ItemPropertyChanged;
}
else
{
CoreLogger.LogError("Unexpected parameter type");
}
}
// Update the Items collection on the UI thread
List<ParameterRunViewModel> removedItems = [];
lock (_listLock)
{
// Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
// you'll clean up list items that we've now transferred into
// .Items
}
// If we removed items, we need to clean them up, to remove our event handlers
foreach (var removedItem in removedItems)
{
removedItem.PropertyChanged -= ItemPropertyChanged;
removedItem.SafeCleanup();
}
}
catch (Exception)
{
// Handle exceptions (e.g., log them)
}
DoOnUiThread(
() =>
{
CoreLogger.LogDebug($"raising parameter items changed, {Items.Count} parameters");
OnPropertyChanged(nameof(Items)); // This _could_ be promoted to a dedicated ItemsUpdated event if needed
UpdateCommand();
WeakReferenceMessenger.Default.Send(new FocusSearchBoxMessage());
});
}
private void UpdateCommand()
{
var showCommand = ShowCommand;
CoreLogger.LogDebug($"showCommand:{showCommand}");
UpdateProperty(nameof(ShowCommand));
if (!showCommand || Command.Model.Unsafe is null)
{
return;
}
UpdateProperty(nameof(Command));
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(Command));
});
}
private void ItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ParameterValueRunViewModel.NeedsValue))
{
UpdateCommand();
}
}
private bool NeedsAnyValues()
{
lock (_listLock)
{
foreach (var item in Items)
{
if (item is ParameterValueRunViewModel val &&
val.NeedsValue)
{
return true;
}
}
}
return false;
}
public void TrySubmit()
{
if (ShowCommand)
{
PerformCommandMessage m = new(this.Command.Command.Model);
WeakReferenceMessenger.Default.Send(m);
}
}
public void FocusNextParameter(ParameterValueRunViewModel lastParam)
{
lock (_listLock)
{
var found = false;
ParameterValueRunViewModel? firstWithoutValue = null;
foreach (var param in Items)
{
if (param == lastParam)
{
found = true;
continue;
}
else if (param is ParameterValueRunViewModel pv)
{
if (found)
{
WeakReferenceMessenger.Default.Send(new FocusParamMessage(pv));
return;
}
else if (firstWithoutValue is null && pv.NeedsValue)
{
firstWithoutValue = pv;
}
}
}
if (firstWithoutValue is not null)
{
WeakReferenceMessenger.Default.Send(new FocusParamMessage(firstWithoutValue));
}
}
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
lock (_listLock)
{
foreach (var item in Items)
{
item.SafeCleanup();
}
Items.Clear();
}
}
}
#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -23,6 +23,7 @@ public class CommandPalettePageViewModelFactory
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host) { IsNested = nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host),
IParametersPage paramsPage => new ParametersPageViewModel(paramsPage, _scheduler, host),
_ => null,
};
}

View File

@@ -7,6 +7,7 @@ using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Ext.Actions;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Bookmarks;
using Microsoft.CmdPal.Ext.Calc;
@@ -155,6 +156,11 @@ public partial class App : Application
services.AddSingleton<ICommandProvider, SystemCommandExtensionProvider>();
services.AddSingleton<ICommandProvider, RemoteDesktopCommandProvider>();
if (ActionsCommandsProvider.IsActionsFeatureEnabled)
{
services.AddSingleton<ICommandProvider, ActionsCommandsProvider>();
}
// Models
services.AddSingleton<TopLevelCommandManager>();
services.AddSingleton<AliasManager>();

View File

@@ -0,0 +1,48 @@
// 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.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Commands;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using CoreVirtualKeyStates = Windows.UI.Core.CoreVirtualKeyStates;
using VirtualKey = Windows.System.VirtualKey;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class ParameterRunTemplateSelector : DataTemplateSelector
{
public DataTemplate? LabelRunTemplate { get; set; }
public DataTemplate? StringParamTemplate { get; set; }
public DataTemplate? ButtonParamTemplate { get; set; }
protected override DataTemplate? SelectTemplateCore(object item)
{
return item switch
{
LabelRunViewModel => LabelRunTemplate,
StringParameterRunViewModel => StringParamTemplate,
CommandParameterRunViewModel => ButtonParamTemplate,
_ => base.SelectTemplateCore(item),
};
}
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container)
{
return SelectTemplateCore(item);
}
}

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.SearchBar"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUi="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:coreVm="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -13,21 +15,86 @@
<UserControl.Resources>
<ResourceDictionary>
<cmdpalUi:PlaceholderTextConverter x:Key="PlaceholderTextConverter" />
<DataTemplate x:Key="LabelRunTemplate" x:DataType="coreVm:LabelRunViewModel">
<StackPanel VerticalAlignment="Center">
<TextBlock VerticalAlignment="Bottom" Text="{x:Bind Text, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="ButtonParamTemplate" x:DataType="coreVm:CommandParameterRunViewModel">
<StackPanel>
<Button
MinHeight="32"
Margin="2,0,2,0"
VerticalAlignment="Center"
VerticalContentAlignment="Stretch"
Command="{x:Bind InvokeCommand, Mode=OneWay}"
Content="{x:Bind DisplayText, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="StringParamTemplate" x:DataType="coreVm:StringParameterRunViewModel">
<TextBox
VerticalAlignment="Center"
KeyDown="StringParameter_KeyDown"
PlaceholderText="{x:Bind PlaceholderText, Mode=OneWay}"
Style="{StaticResource SearchParameterTextBoxStyle}"
Text="{x:Bind TextForUI, Mode=OneWay}"
TextChanged="StringParameter_TextChanged" />
</DataTemplate>
<DataTemplate x:Key="ListParamTemplate" x:DataType="coreVm:CommandParameterRunViewModel">
<TextBox
VerticalAlignment="Center"
PlaceholderText="{x:Bind PlaceholderText, Mode=OneWay}"
Style="{StaticResource SearchParameterTextBoxStyle}"
Text="{x:Bind SearchBoxText, Mode=TwoWay}" />
</DataTemplate>
<cpcontrols:ParameterRunTemplateSelector
x:Key="ParameterRunTemplateSelector"
ButtonParamTemplate="{StaticResource ButtonParamTemplate}"
LabelRunTemplate="{StaticResource LabelRunTemplate}"
StringParamTemplate="{StaticResource StringParamTemplate}" />
</ResourceDictionary>
</UserControl.Resources>
<!-- Search box -->
<TextBox
x:Name="FilterBox"
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"
PreviewKeyDown="FilterBox_PreviewKeyDown"
PreviewKeyUp="FilterBox_PreviewKeyUp"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="FilterBox_TextChanged" />
<!-- Disabled Description="{x:Bind CurrentPageViewModel.TextToSuggest, Mode=OneWay}" for now, needs more work -->
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:String"
Value="{x:Bind PageType, Mode=OneWay}">
<controls:Case Value="List">
<!-- Search box -->
<TextBox
x:Name="FilterBox"
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"
PreviewKeyDown="FilterBox_PreviewKeyDown"
PreviewKeyUp="FilterBox_PreviewKeyUp"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="FilterBox_TextChanged" />
<!-- Disabled Description="{x:Bind CurrentPageViewModel.TextToSuggest, Mode=OneWay}" for now, needs more work -->
</controls:Case>
<controls:Case Value="Parameters">
<ItemsControl
x:Name="ParametersBar"
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
IsTabStop="False"
ItemTemplateSelector="{StaticResource ParameterRunTemplateSelector}"
ItemsSource="{x:Bind Parameters, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</controls:Case>
</controls:SwitchPresenter>
</UserControl>

View File

@@ -2,14 +2,18 @@
// 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.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Commands;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.Views;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
@@ -22,9 +26,11 @@ using VirtualKey = Windows.System.VirtualKey;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
INotifyPropertyChanged,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
IRecipient<FocusParamMessage>,
ICurrentPageAware
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
@@ -51,6 +57,8 @@ public sealed partial class SearchBar : UserControl,
// 0.6+ suggestions
private string? _textToSuggest;
private bool _tokenSearchEnabled;
private SettingsModel Settings => App.Current.Services.GetRequiredService<SettingsModel>();
public PageViewModel? CurrentPageViewModel
@@ -63,6 +71,8 @@ public sealed partial class SearchBar : UserControl,
public static readonly DependencyProperty CurrentPageViewModelProperty =
DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(SearchBar), new PropertyMetadata(null, OnCurrentPageViewModelChanged));
public event PropertyChangedEventHandler? PropertyChanged;
private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
//// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work...
@@ -83,15 +93,37 @@ public sealed partial class SearchBar : UserControl,
@this.FilterBox.Select(@this.FilterBox.Text.Length, 0);
page.PropertyChanged += @this.Page_PropertyChanged;
if (page is ListViewModel listViewModel)
{
@this._tokenSearchEnabled = listViewModel.IsTokenSearch;
}
}
@this?.PropertyChanged?.Invoke(@this, new(nameof(PageType)));
@this?.PropertyChanged?.Invoke(@this, new(nameof(Parameters)));
// Attempt to focus us again, once we evaluate what input is visible
@this?.Focus();
}
public string PageType => CurrentPageViewModel switch
{
ListViewModel => "List",
ContentPageViewModel => "Content",
ParametersPageViewModel => "Parameters",
_ => string.Empty,
};
public ObservableCollection<ParameterRunViewModel> Parameters { get; } = new();
public SearchBar()
{
this.InitializeComponent();
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
WeakReferenceMessenger.Default.Register<FocusParamMessage>(this);
}
public void ClearSearch()
@@ -200,6 +232,43 @@ public sealed partial class SearchBar : UserControl,
{
// Mark backspace as held to handle continuous deletion
_isBackspaceHeld = true;
// Try to handle token deletion
if (_tokenSearchEnabled &&
FilterBox.SelectionLength == 0)
{
// Tokens are delimited by zero-width space characters
// (ZWSP, U+200B).
//
// What we're gonna do here is check if the character we're
// about to backspace is the _second_ ZWSP in a pair
var lastCaretPosition = FilterBox.SelectionStart;
var text = FilterBox.Text;
// Look at the character before the caret. Is it a zwsp?
if (lastCaretPosition > 0 &&
text[lastCaretPosition - 1] == '\u200B')
{
// make sure that this is a pair. So, we'd need to see an odd number of zwsp's before this one.
var zwspCount = 0;
var previousZwspIndex = -1;
for (var i = 0; i < lastCaretPosition - 1; i++)
{
if (text[i] == '\u200B')
{
zwspCount++;
previousZwspIndex = i;
}
}
if (zwspCount % 2 == 1 && previousZwspIndex != -1)
{
// We have a pair! Select the whole token for deletion
FilterBox.Select(previousZwspIndex, lastCaretPosition - previousZwspIndex);
e.Handled = true;
}
}
}
}
}
else if (e.Key == VirtualKey.Up)
@@ -413,11 +482,43 @@ public sealed partial class SearchBar : UserControl,
SelectSearch();
}
}
else if (CurrentPageViewModel is ParametersPageViewModel parametersPage)
{
if (property == nameof(ParametersPageViewModel.Items))
{
CoreLogger.LogDebug($"handling parameter items changed, {parametersPage.Items.Count} parameters");
this.DispatcherQueue.TryEnqueue(UpdateParameters);
}
}
}
private void UpdateParameters()
{
var newParams = GetParameters();
ListHelpers.InPlaceUpdateList(Parameters, newParams);
}
private List<ParameterRunViewModel> GetParameters()
{
var res = CurrentPageViewModel is ParametersPageViewModel page ? page.Items : null;
CoreLogger.LogDebug($"retrieving parameters, {res?.Count ?? 0} parameters");
return res ?? new();
}
public void Receive(GoHomeMessage message) => ClearSearch();
public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
public void Receive(FocusSearchBoxMessage message) => Focus();
private void Focus()
{
this.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{
if (FocusManager.FindFirstFocusableElement(this) is DependencyObject focusable)
{
FocusManager.TryFocusAsync(focusable, FocusState.Keyboard).Wait();
}
});
}
public void Receive(UpdateSuggestionMessage message)
{
@@ -527,4 +628,45 @@ public sealed partial class SearchBar : UserControl,
return !string.IsNullOrEmpty(env) &&
(env == "1" || env.Equals("true", System.StringComparison.OrdinalIgnoreCase));
}
private void StringParameter_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox && textBox.DataContext is StringParameterRunViewModel stringParam)
{
stringParam.SetTextFromUi(textBox.Text);
}
}
private void StringParameter_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (sender is TextBox textBox &&
textBox.DataContext is StringParameterRunViewModel stringParam &&
CurrentPageViewModel is ParametersPageViewModel parametersPage)
{
if (e.Key == VirtualKey.Enter)
{
if (parametersPage.ShowCommand)
{
parametersPage.TrySubmit();
}
else
{
parametersPage.FocusNextParameter(stringParam);
}
}
}
}
public void Receive(FocusParamMessage message)
{
var parameter = message.Parameter;
if (parameter != null)
{
var container = ParametersBar.ContainerFromItem(parameter);
if (container is FrameworkElement element)
{
element.Focus(FocusState.Keyboard);
}
}
}
}

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.ParametersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Interactions="using:Microsoft.Xaml.Interactions.Core"
xmlns:Interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:cmdPalControls="using:Microsoft.CmdPal.UI.Controls"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary />
</Page.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.ShowCommand, Mode=OneWay}">
<controls:Case Value="False">
<!--
what kind of content do we want on this page before it
has a command? Anything? Nothing?
-->
</controls:Case>
<controls:Case Value="True">
<StackPanel
Margin="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="4">
<cmdPalControls:IconBox
x:Name="IconBorder"
Width="48"
Height="48"
Margin="8"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind ViewModel.Command.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.Command.Title, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.Command.Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</controls:Case>
</controls:SwitchPresenter>
</Grid>
</Page>

View File

@@ -0,0 +1,99 @@
// 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;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ParametersPage : Page
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
public ParametersPageViewModel? ViewModel
{
get => (ParametersPageViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
// Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(ParametersPageViewModel), typeof(ParametersPage), new PropertyMetadata(null, OnViewModelChanged));
public ParametersPage()
{
this.InitializeComponent();
this.Unloaded += OnUnloaded;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// Unhook from everything to ensure nothing can reach us
// between this point and our complete and utter destruction.
WeakReferenceMessenger.Default.UnregisterAll(this);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is not AsyncNavigationRequest navigationRequest)
{
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
}
if (navigationRequest.TargetViewModel is not ParametersPageViewModel page)
{
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ParametersPageViewModel)}");
}
ViewModel = page;
base.OnNavigatedTo(e);
}
protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
base.OnNavigatingFrom(e);
// Clean-up event listeners
ViewModel = null;
}
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ParametersPage @this)
{
if (e.OldValue is ParametersPageViewModel old)
{
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
}
if (e.NewValue is ParametersPageViewModel page)
{
page.PropertyChanged += @this.ViewModel_PropertyChanged;
}
else if (e.NewValue is null)
{
CoreLogger.LogDebug("cleared view model");
}
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var prop = e.PropertyName;
if (prop == nameof(ViewModel.ShowCommand))
{
Debug.WriteLine($"ViewModel.ShowCommand {ViewModel?.ShowCommand}");
}
}
}

View File

@@ -8,6 +8,7 @@ using CmdPalKeyboardService;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Messages;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
@@ -50,6 +51,7 @@ public sealed partial class MainWindow : WindowEx,
IRecipient<ShowWindowMessage>,
IRecipient<HideWindowMessage>,
IRecipient<QuitMessage>,
IRecipient<GetHwndMessage>,
IDisposable
{
private const int DefaultWidth = 800;
@@ -106,6 +108,7 @@ public sealed partial class MainWindow : WindowEx,
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
WeakReferenceMessenger.Default.Register<ShowWindowMessage>(this);
WeakReferenceMessenger.Default.Register<HideWindowMessage>(this);
WeakReferenceMessenger.Default.Register<GetHwndMessage>(this);
// Hide our titlebar.
// We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed
@@ -1006,4 +1009,9 @@ public sealed partial class MainWindow : WindowEx,
_localKeyboardListener.Dispose();
DisposeAcrylic();
}
public void Receive(GetHwndMessage message)
{
message.Hwnd = this.GetWindowHandle();
}
}

View File

@@ -120,6 +120,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Actions\Microsoft.CmdPal.Ext.Actions.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.RemoteDesktop\Microsoft.CmdPal.Ext.RemoteDesktop.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.System\Microsoft.CmdPal.Ext.System.csproj" />

View File

@@ -154,6 +154,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
{
ListViewModel => typeof(ListPage),
ContentPageViewModel => typeof(ContentPage),
ParametersPageViewModel => typeof(ParametersPage),
_ => throw new NotSupportedException(),
},
new AsyncNavigationRequest(message.Page, message.CancellationToken),

View File

@@ -9,6 +9,25 @@
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>
<ResourceDictionary.ThemeDictionaries>
<!-- For slightly adjust the LayerOnAcrylicFillColorDefault color so that the cursor of the searchbox shows -->
<ResourceDictionary x:Key="Default">
<SolidColorBrush
x:Key="ParameterBackground"
Opacity="0.2"
Color="{ThemeResource SystemAccentColor}" />
<SolidColorBrush x:Key="ParameterBackgroundFocused" Color="{ThemeResource ControlFillColorSecondary}" />
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<SolidColorBrush
x:Key="ParameterBackground"
Opacity="0.08"
Color="{ThemeResource SystemAccentColor}" />
<SolidColorBrush x:Key="ParameterBackgroundFocused" Color="{ThemeResource SolidBackgroundFillColorSecondary}" />
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast" />
</ResourceDictionary.ThemeDictionaries>
<converters:StringVisibilityConverter
x:Key="ReverseStringVisibilityConverter"
EmptyValue="Visible"
@@ -278,4 +297,109 @@
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SearchParameterTextBoxStyle" TargetType="TextBox">
<Setter Property="Foreground" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Property="Background" Value="{ThemeResource ParameterBackground}" />
<Setter Property="PlaceholderForeground" Value="{ThemeResource AccentFillColorDefaultBrush}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="SelectionHighlightColor" Value="{ThemeResource TextControlSelectionHighlightColor}" />
<Setter Property="BorderThickness" Value="{ThemeResource TextControlBorderThemeThickness}" />
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
<Setter Property="ScrollViewer.HorizontalScrollMode" Value="Auto" />
<Setter Property="ScrollViewer.VerticalScrollMode" Value="Auto" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.IsDeferredScrollingEnabled" Value="False" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Padding" Value="6,2,6,2" />
<Setter Property="UseSystemFocusVisuals" Value="{ThemeResource IsApplicationFocusVisualKindReveal}" />
<Setter Property="ContextFlyout" Value="{StaticResource TextControlCommandBarContextFlyout}" />
<Setter Property="SelectionFlyout" Value="{StaticResource TextControlCommandBarSelectionFlyout}" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Grid>
<Border
x:Name="BorderElement"
MinWidth="{TemplateBinding MinWidth}"
MinHeight="{TemplateBinding MinHeight}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{TemplateBinding CornerRadius}" />
<ScrollViewer
x:Name="ContentElement"
Margin="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
AutomationProperties.AccessibilityView="Raw"
Foreground="{TemplateBinding Foreground}"
HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}"
HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}"
IsDeferredScrollingEnabled="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}"
IsHorizontalRailEnabled="{TemplateBinding ScrollViewer.IsHorizontalRailEnabled}"
IsTabStop="False"
IsVerticalRailEnabled="{TemplateBinding ScrollViewer.IsVerticalRailEnabled}"
VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}"
VerticalScrollMode="{TemplateBinding ScrollViewer.VerticalScrollMode}"
ZoomMode="Disabled" />
<TextBlock
x:Name="PlaceholderTextContentPresenter"
Margin="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
Foreground="{TemplateBinding PlaceholderForeground}"
IsHitTestVisible="False"
Text="{TemplateBinding PlaceholderText}"
TextAlignment="{TemplateBinding TextAlignment}"
TextWrapping="{TemplateBinding TextWrapping}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlBackgroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="BorderBrush">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlBorderBrushDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentElement" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlForegroundDisabled}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="PlaceholderTextContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{Binding PlaceholderForeground, RelativeSource={RelativeSource TemplatedParent}, TargetNullValue={ThemeResource TextControlPlaceholderForegroundDisabled}}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
<VisualState x:Name="PointerOver" />
<VisualState x:Name="Focused">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="PlaceholderTextContentPresenter" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextFillColorPrimaryBrush}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="Background">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ParameterBackgroundFocused}" />
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentElement" Storyboard.TargetProperty="Foreground">
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextFillColorPrimaryBrush}" />
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="ButtonStates">
<VisualState x:Name="ButtonVisible" />
<VisualState x:Name="ButtonCollapsed" />
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -60,7 +60,7 @@ A markdown page is a page inside of command palette that displays markdown conte
```csharp
interface IMarkdownPage requires IPage {
String[] Bodies(); // TODO! should this be an IBody, so we can make it observable?
String[] Bodies();
IDetails Details();
IContextItem[] Commands { get; };
}

View File

@@ -0,0 +1,121 @@
class MyPrefixCommandProvider : ICommandProvider
{
private PeopleDynamicListPage _peopleCommand; // com.contoso.people
private CommandsListPage _commandsCommand; // com.contoso.commands
private AddFileCommand _addFileCommand; // com.contoso.addFile
private PrefixSearchPage _prefixSearchPage;
public MyPrefixCommandProvider()
{
_prefixSearchPage = new PrefixSearchPage();
var tokenPickedHandler = _prefixSearchPage.HandleTokenPicked;
_peopleCommand = new PeopleDynamicListPage(tokenPickedHandler);
_commandsCommand = new CommandsListPage(tokenPickedHandler);
_addFileCommand = new AddFileCommand(tokenPickedHandler);
}
public ICommandItem[] GetTopLevelCommands()
{
return new ICommandItem[] { _prefixSearchPage };
}
public ICommand GetCommand(String id)
{
if (id == "com.contoso.people")
{
return _peopleCommand;
}
else if (id == "com.contoso.commands")
{
return _commandsCommand;
}
else if (id == "com.contoso.addFile")
{
return _addFileCommand;
}
return null;
}
}
class PrefixSearchPage : ListPage, IPrefixProvider
{
public IDictionary<String, String> PrefixCommands => new Dictionary<String, String>
{
{ "@", "com.contoso.people" },
{ "/", "com.contoso.commands" },
{ "+", "com.contoso.addFile" },
};
public event Windows.Foundation.TypedEventHandler<Object, ITokenPickedEventArgs> TokenAdded;
public PrefixSearchPage()
{
// Initialize the page...
}
public void SendQuery(ISearchUpdateArgs args)
{
// Handle the search update, possibly updating the list of items based on the new search text
var searchText = args.NewSearchText;
var properties = args.GetProperties();
var tokens = properties.TryLookup<object>("tokens") as ITokenPositions[];
// Here you could use these tokens to update the commands in our own search results
// Or just save them, and plumb them into the InvokableCommand the user eventually picks
}
public void HandleTokenPicked(object sender, ITokenPickedEventArgs args)
{
// Handle the token picked event, e.g., log it or update UI
var token = args.Token;
var text = token.DisplayText;
// Do something with the picked token
}
// Other ListPage members...
}
public class PeopleDynamicListPage : DynamicListPage
{
private Windows.Foundation.TypedEventHandler<Object, ITokenPickedEventArgs> _tokenPicked;
public PeopleDynamicListPage(TypedEventHandler<Object, ITokenPickedEventArgs> onTokenPicked)
{
_tokenPicked = onTokenPicked;
}
public override IListItem[] GetItems()
{
// Return the list of people items
return new IListItem[]
{
new PersonSuggestionItem("Alice", _tokenPicked),
new PersonSuggestionItem("Bob", _tokenPicked),
new PersonSuggestionItem("Charlie", _tokenPicked)
};
}
// Other DynamicListPage members...
}
public class PersonSuggestionItem : SuggestionListItem { /* ... */ }
public class SuggestionListItem : SuggestionListItem
{
private Windows.Foundation.TypedEventHandler<Object, ITokenPickedEventArgs> _tokenPicked;
private object _tokenValue;
public SuggestionListItem(object token, TypedEventHandler<Object, ITokenPickedEventArgs> onTokenPicked)
{
_tokenValue = token;
_tokenPicked = onTokenPicked;
}
public override ICommandResult Invoke()
{
_tokenPicked?.Invoke(this, new TokenPickedEventArgs(_tokenValue));
return CommandResult.KeepOpen;
}
}

View File

@@ -0,0 +1,513 @@
---
author: Mike Griese
created on: 2025-09-04
last updated: 2025-09-08
issue id: n/a
---
## Addenda II-D: Parameters page
```c#
[uuid("a2590cc9-510c-4af7-b562-a6b56fe37f55")]
interface IParameterRun requires INotifyPropChanged
{
};
interface ILabelRun requires IParameterRun
{
String Text { get; };
};
interface IParameterValueRun requires IParameterRun
{
String PlaceholderText{ get; };
Boolean NeedsValue{ get; }; // TODO! name is weird
};
interface IStringParameterRun requires IParameterValueRun
{
String Text{ get; set; };
// TODO! do we need a way to validate string inputs?
};
interface ICommandParameterRun requires IParameterValueRun
{
String DisplayText{ get; };
ICommand GetSelectValueCommand(UInt64 hostHwnd);
IIconInfo Icon{ get; }; // ? maybe
};
interface IParametersPage requires IPage
{
IParameterRun[] Parameters { get; };
IListItem Command { get; };
};
```
When we open a `IParametersPage`, we will render the `Parameters` in the search
box. We'll move focus to the first `IParameterRun` that is not a `ILabelRun`.
What those interactions looks like depends on the type of `IParameterRun`.
There are three basic types of inputs: strings, invokable commands, and lists.
Strings are a special case that doesn't require a command to set the value.
Lists and invokable commands are picked based on the type of the
`SelectValueCommand`. Each of these are detailed below.
When all the parameters have `NeedsValue` set to `false`, we will display a
single item to the user - the `Command` item.
### String parameters
These are rendered as a text box within the search box. The user can type into
it. Focus is moved to the next parameter when the user presses Enter or tab.
### Command parameters - Invokable Commands
These are used when the `SelectValueCommand` is an `IInvokableCommand`.
These are rendered as a button within the search box. The button text is
`DisplayText` if it is set, otherwise it is `PlaceholderText`. If the user
clicks the button, we invoke the `SelectValueCommand` (and ignore the `CommandResult`).
This is good for file pickers, date pickers, color pickers, etc. Anything that
requires a custom UI to pick a value.
When the extension has picked a value, it should set the `NeedsValue` to false.
The extension can also set the `DisplayText` and `Icon` to reflect the chosen value.
When the user presses enter with the button focused, we will also invoke the
`SelectValueCommand`.
When the user presses tab, we will move focus to the next parameter.
If the `NeedsValue` property is changed to `false` while it's focused, we will
move focus to the next parameter.
### Command parameters - List Commands
These are used when the `SelectValueCommand` is an `IListPage` - both static and
dynamic lists work similarly.
These are rendered as a text box within the search box. When the user focuses
the text box, we will display the items from the `IListPage` in the body of
CmdPal. The user can then type to filter the list. This filtering will work the
same way as any other list page in CmdPal - CmdPal will filter static lists, or
pass the query to a dynamic list.
The items in this list should all be `IListItem` objects with
`IInvokableCommands`. Putting a `IPage` into one of these items will cause the
user to navigate away from the parameters page, which would probably be
unexpected.
When the user picks an item from the list, the extension should handle that
command by bubbling an event up to the `CommandRun`, and setting the `Value`,
`DisplayText`, and `Icon` properties, and setting `NeedsValue` to false.
When the user presses enter with the text box focused, we will invoke the
command of the selected item in the list.
When the user presses tab, we will move focus to the next parameter.
If the `NeedsValue` property is changed to `false` while it's focused, we will
move focus to the next parameter.
### Example
Lets say you had a command like "Create a note \${title} in \${folder}".
`title` is a string input, and `folder` is a static list of folders.
The extension author can then define a `IParametersPage` with four runs in it:
* A `ILabelRun` for "Create a note"
* A `IStringParameterRun` for the `title`
* A `ILabelRun` for "in"
* A `ICommandParameterRun` for the `folder`. The `Command` will be a `IListPage`, where the items are possible folders
In this example, the user can pick the "create note" command, then type the title, hit enter/tab, and then pick a folder from the list, then hit enter to run the command.
```cs
public interface IRequiresHostHwnd
{
void SetHostHwnd(UInt64 hostHwnd);
}
public sealed partial class CommandParameterRun : BaseObservable, ICommandParameterRun
{
public virtual string DisplayText { get; set; } // basic projected properties here, same as throughout the toolkit
public virtual string PlaceholderText { get; set; } // basic projected properties here, same as throughout the toolkit
public virtual ICommand Command { get; set; } // basic projected properties here, same as throughout the toolkit
public virtual IIconInfo Icon { get; set; } // basic projected properties here, same as throughout the toolkit
public virtual bool NeedsValue => Value == null; // Toolkit helper: does this parameter need a value?
public virtual ICommand GetSelectValueCommand(UInt64 hostHwnd)
{
if (Command is IRequiresHostHwnd requiresHwnd)
{
requiresHwnd.SetHostHwnd(hostHwnd);
}
return Command;
}
public object? Value { get; set; } // Toolkit helper: a value for the parameter
}
public sealed partial class CreateNoteParametersPage : ParametersPage
{
private readonly SelectFolderPage _selectFolderPage = new SelectFolderPage();
private readonly StringParameterRun _titleParameter = new StringParameterRun()
{
PlaceholderText = "Note title"
};
private readonly ICommandParameterRun _folderParameter = new CommandParameterRun()
{
PlaceholderText = "Select folder",
Command = _selectFolderPage
};
private readonly List<IParameterRun> _parameters;
private readonly CreateNoteCommand _command = new() { TitleParameter = _titleParameter, FolderParameter = _folderParameter };
private readonly ListItem _item = new(_command);
public IParameterRun[] Parameters => _parameters.ToArray();
public IListItem Command => _item;
public CreateNoteParametersPage()
{
_parameters = new List<IParameterRun>
{
new LabelRun("Create a note"),
_titleParameter,
new LabelRun("in"),
_folderParameter
};
_selectFolderPage.FolderSelected += (s, folder) =>
{
_folderParameter.Value = folder;
_folderParameter.Icon = folder.Icon;
_folderParameter.DisplayText = folder.Name;
};
};
}
public sealed partial class CreateNoteCommand : BaseObservable, IInvokableCommand
{
internal IStringParameterRun TitleParameter { get; init; } // set by the parameters page
internal ICommandParameterRun FolderParameter { get; init; } // set by the parameters page
public IIconInfo Icon => new IconInfo("NoteAdd");
public override ICommandResult Invoke()
{
var title = TitleParameter.Text;
if (string.IsNullOrWhiteSpace(title))
{
var t = new ToastStatusMessage(new StatusMessage(){ Title = "Title is required", State = MessageState.Error });
t.Show();
return CommandResult.KeepOpen();
}
var folder = FolderParameter.Value;
if (folder is not Folder)
{
// This is okay, we'll create the note in the default folder
}
// Create the note in the specified folder
NoteService.CreateNoteInFolder(title, folder); // whatever your backend is
return CommandResult.Dismiss();
}
}
public sealed partial class SelectFolderPage : ListPage
{
public event EventHandler<Folder>? FolderSelected;
public SelectFolderPage()
{
// Populate the list with folders
var folders = FolderService.GetFolders(); // whatever your backend is
Items = folders.Select(f => new ListItem(new SelectFolderCommand(f), f.Name, f.Icon)).ToArray();
}
private sealed partial class SelectFolderCommand : BaseObservable, IInvokableCommand
{
private readonly EventHandler<Folder> _folderSelected;
private readonly Folder _folder;
public IIconInfo Icon => _folder.Icon;
public string Title => _folder.Name;
public SelectFolderCommand(Folder folder, EventHandler<Folder> folderSelected)
{
_folder = folder;
_folderSelected = folderSelected;
}
public override ICommandResult Invoke()
{
_folderSelected?.Invoke(this, _folder);
return CommandResult.KeepOpen();
}
}
}
public sealed partial class FilePickerParameterRun : CommandParameterRun
{
public StorageFile? File { get; private set;}
public FilePickerParameterRun()
{
var command = new FilePickerCommand();
command.FileSelected += (file) =>
{
File = file;
if (file != null)
{
Value = file;
DisplayText = file.Name;
// Icon = new IconInfo("File");
}
else
{
Value = null;
DisplayText = null;
// Icon = new IconInfo("File");
}
};
PlaceholderText = "Select a file";
Icon = new IconInfo("File");
Command = command;
}
private sealed partial class FilePickerCommand : InvokableCommand, IRequiresHostHwnd
{
public IIconInfo Icon => new IconInfo("File");
public string Name => "Pick a file";
public event EventHandler<StorageFile?>? FileSelected;
private uint _hostHwnd;
public void SetHostHwnd(uint hostHwnd)
{
_hostHwnd = hostHwnd;
}
public override ICommandResult Invoke()
{
PickFileAsync();
return CommandResult.KeepOpen();
}
private async void PickFileAsync()
{
var picker = new Windows.Storage.Pickers.FileOpenPicker();
// You need to initialize the picker with a window handle in WinUI 3 desktop apps
// See https://learn.microsoft.com/en-us/windows/apps/design/controls/file-open-picker
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow);
WinRT.Interop.InitializeWithWindow.Initialize(picker, _hostHwnd);
var file = await picker.PickSingleFileAsync();
FileSelected?.Invoke(this, file);
}
}
}
public sealed partial class SelectParameterCommand<T> : InvokableCommand
{
public event TypedEventHandler<object, T>? ValueSelected;
private T _value;
public T Value { get => _value; protected set { _value = value; } }
public SelectParameterCommand(T value)
{
_value = value;
}
public override ICommandResult Invoke()
{
ValueSelected?.Invoke(this, _value);
return CommandResult.KeepOpen();
}
}
public sealed partial class StaticParameterList<T> : ListPage
{
public event TypedEventHandler<object, T>? ValueSelected;
private bool _isInitialized = false;
private readonly IEnumerable<T> _values;
private readonly List<IListItem> _items = new List<IListItem>();
private Func<T, ListItem, ListItem> _customizeListItemsCallback;
// ctor takes an IEnumerable<T> values, and a function to customize the ListItem's depending on the value
public StaticParameterList(IEnumerable<T> values, Func<T, ListItem> customizeListItem)
{
_values = values;
_customizeListItemsCallback = (value, listItem) => { customizeListItem(value); return listItem; };
}
}
public StaticParameterList(IEnumerable<T> values, Func<T, ListItem, ListItem> customizeListItem)
{
_values = values;
_customizeListItemsCallback = customizeListItem;
}
public override IListItem[] GetItems()
{
if (!_isInitialized)
{
Initialize(_values, _customizeListItemsCallback);
_isInitialized = true;
}
return _items.ToArray();
}
private void Initialize(IEnumerable<T> values, Func<T, ListItem, ListItem> customizeListItem)
{
foreach (var value in values)
{
var command = new SelectParameterCommand<T>(value);
command.ValueSelected += (s, v) => ValueSelected?.Invoke(this, v);
var listItem = new ListItem(command);
var item = customizeListItem(value, listItem);
_items.Add(item);
}
}
}
```
--------------------------------------------------------
## original draft starts here
### Arbitrary parameters and arguments
Something we'll want to consider soon is how to allow for arbitrary parameters
to be passed to commands. This allows for commands to require additional info from
the user _before_ they are run. In its simplest form, this is a lightweight way
to have an action accept form data inline with the query. But this also allows
for highly complex action chaining.
I had originally started to spec this out as:
```cs
enum ParameterType
{
Text,
File,
Files,
Enum,
Entity
};
interface ICommandParameter
{
ParameterType Type { get; };
String Name { get; };
Boolean Required{ get; };
// TODO! values for enums?
// TODO! dynamic values for enums? like GetValues(string query)
// TODO! files might want to restrict types? but now we're a file picker and need that whole API
// TODO! parameters with more than one value? Like,
// SendMessage(People[] to, String message)
};
interface ICommandArgument
{
String Name { get; };
Object Value { get; };
};
interface IInvokableCommandWithParameters requires ICommand {
ICommandParameter[] Parameters { get; };
ICommandResult InvokeWithArgs(Object sender, ICommandArgument[] args);
};
```
TODO! Mike:
We should add like, a `CustomPicker` parameter type, which would allow
extensions to define their own custom pickers for parameters. Then when we go to fill the argument, we'd call something like `ShowPickerAsync(ICommandParameter param)` and let them fill in the value. We don't care what the value is.
So it'd be more like
```c#
enum ParameterType
{
Text,
// File,
// Files,
Enum,
Custom
};
// interface IArgumentEnumValue requires INotifyPropChanged
// {
// String Name { get; };
// IIconInfo Icon { get; };
// }
interface ICommandArgument requires INotifyPropChanged
{
ParameterType Type { get; };
String Name { get; };
Boolean Required{ get; };
Object Value { get; set; };
String DisplayName { get; };
IIconInfo Icon { get; };
void ShowPicker(UInt64 hostHwnd);
// todo
// IArgumentEnumValue[] GetValues();
};
interface IInvokableCommandWithParameters requires ICommand {
ICommandArgument[] Parameters { get; };
ICommandResult InvokeWithArgs(Object sender, ICommandArgument[] args);
};
```
And `CommandParameters` would be a set of `{ type, name, required }` structs,
which would specify the parameters that the action needs. Simple types would be
`string`, `file`, `file[]`, `enum` (with possible values), etc.
But that may not be complex enough. We recently learned about Action Framework
and some of their plans there - that may be a good fit for this. My raw notes
follow - these are not part of the current SDK spec.
> [!NOTE]
>
> A thought: what if a action returns a `CommandResult.Entity`, then that takes
> devpal back home, but leaves the entity in the query box. This would allow for
> a Quicksilver-like "thing, do" flow. That command would prepopulate the
> parameters. So we would then filter top-level commands based on things that can
> accept the entity in the search box.
>
> For example: The user uses the "Search for file" list page. They find the file
> they're looking for. That file's ListItem has a context item "With
> {filename}..." that then returns a `CommandResult.Entity` with the file entity.
> The user is taken back to the main page, and a file picker badge (with that
> filename) is at the top of the search box. In that state, the only commands
> now shown are ones that can accept a File entity. This could be things like
> the "Remove background" action (from REDACTED), the "Open with" action, the
> "Send to Teams chat" (which would then ask for another entity). If they did
> the "Remove Background" one, that could then return _another_ entity.
>
> We'd need to solve for the REDACTED case specifically, cause I bet they want to
> stay in the REDACTED action page, rather than the main one.
>
> We'd also probably want the REDACTED one to be able to accept arbitrary
> entities... like, they probably want a `+` button that lets you add... any
> kind of entity to their page, rather than explicitly ask for a list of args.
However, we do not have enough visibility on how action framework actually
works, consumer-wise, to be able to specify more. As absolutely fun as chaining
actions together sounds, I've decided to leave this out of the official v1 spec.
We can ship a viable v0.1 of DevPal without it, and add it in post.

View File

@@ -0,0 +1,152 @@
---
author: Mike Griese
created on: 2025-09-08
last updated: 2025-09-08
issue id: n/a
---
## Addenda II-C: Plain Rich Search
What if adding a whole bunch of new interfaces, we just _fake it_.
We'll just use embedded zero-width space (ZWSP) characters in the search text to "bracket" tokens.
We'll add an extended attribute to the page - something like `TokenSearch`. If
that's set to true, CmdPal will render the search box as a rich text box that
can contain tokens. When we do that, we'll treat text between ZWSP characters as
tokens, and give them special UI treatment (like a link).
When the user types a special prefix (like `@`), the extension page will
internally swap it's items with a list of suggestions. When the user picks one,
the page will raise a search update event with the new search text. That new
text will have the "token" embedded in it, bracketed by ZWSP characters.
> [!INFO]
>
> This is a draft `.idl` spec. Details are still subject to change. Overall
> concepts however will likely remain similar
```c# prefix search
interface ISearchUpdateArgs requires IExtendedAttributesProvider
{
String NewSearchText { get; } // The text that the user has typed into the search box.
// Extended attributes:
// * CaretPosition (int): The current position of the cursor in the search text, maybe?
}
interface IDynamicListPage2 requires IDynamicListPage
{
void UpdateSearch(ISearchUpdateArgs args);
}
```
```cs
class MySuggestionSearchPage : DynamicListPage, IDynamicListPage2
{
private PeopleSearchPage _peopleSearchPage = new();
private CommandsListPage _commandsListPage = new();
private DynamicListPage? _suggestionPage = null;
private List<MyTokenType> _pickedTokens = new();
private int _lastCaretPosition = 0;
MySuggestionSearchPage()
{
_peopleSearchPage.SuggestionPicked += OnSuggestionPicked;
_commandsListPage.SuggestionPicked += OnSuggestionPicked;
}
public IListItem[] GetItems()
{
return _suggestionPage?.GetItems() ?? Array.Empty<IListItem>();
}
public void UpdateSearch(ISearchUpdateArgs args)
{
if (args.GetProperties() is IDictionary<string, object> props)
{
if (props.TryGetValue("CaretPosition", out var caretPosObj) && caretPosObj is int caretPos)
{
_lastCaretPosition = caretPos;
}
}
var oldSearchText = this.SearchText;
var newSearchText = args.NewSearchText;
if (newSearchText.Length < oldSearchText.Length)
{
HandleDeletion(oldSearchText, newSearchText);
return;
}
this.SearchText = newSearchText;
if (_suggestionPage == null){
var lastChar = newSearchText.Length > 0 && _lastCaretPosition > 0 ?
newSearchText[_lastCaretPosition - 1] :
'\0';
if (lastChar == '@')
{
// User typed '@', switch to people suggestion page
UpdateSuggestionPage(_peopleSearchPage);
}
else if (lastChar == '#')
{
// User typed '#', switch to commands suggestion page
UpdateSuggestionPage(_commandsListPage);
}
}
else if (_suggestionPage != null)
{
// figure out what part of the text applies to the current suggestion page
var subString = /* omitted */;
_suggestionPage.SearchText = subString;
// When the suggestion page updates its items, it should raise ItemsChanged event, which we will bubble through
}
}
private void OnSuggestionPicked(object sender, MyTokenType suggestion)
{
_pickedTokens.Add(suggestion);
UpdateSuggestionPage(null); // Clear suggestion page
var displayText = suggestion.DisplayName;
var tokenText = $"\u200B{displayText}\u200B "; // Add ZWSP before and after token, and a trailing space
this.SearchText = this.SearchText.Insert(_lastCaretPosition, tokenText);
}
private void UpdateSuggestionPage(DynamicListPage? page)
{
if (_suggestionPage != null)
{
_suggestionPage.ItemsChanged -= OnSuggestedItemsChanged;
}
_suggestionPage = page;
if (_suggestionPage != null)
{
_suggestionPage.SearchText = string.Empty; // reset search text
_suggestionPage.ItemsChanged += OnSuggestedItemsChanged;
}
RaiseItemsChanged();
}
private void OnSuggestedItemsChanged(object sender, IItemsChangedEventArgs e)
{
RaiseItemsChanged();
}
}
```
------------
None of this solves the "Command with parameters" problem. We'd still need to introduce a new page type for that.
It needs to be a new page type, because we need aggressively change the search box to only allow inputs into the parameter fields. We can't have users removing part of the command's display text - that doesn't make sense.

View File

@@ -0,0 +1,517 @@
---
author: Mike Griese
created on: 2025-09-04
last updated: 2025-09-08
issue id: n/a
---
## Addenda II-B: Prefix Search
> [!INFO]
>
> This is a draft `.idl` spec. Details are still subject to change. Overall
> concepts however will likely remain similar
```c# prefix search
interface ISearchUpdateArgs requires IExtendedAttributesProvider
{
String NewSearchText { get; } // The text that the user has typed into the search box.
}
interface IToken
{
Object Value { get; };
String DisplayText { get; };
String Id { get; }; // GUID?
}
interface IAddTokenArgs
{
IToken Value { get; };
Int64 Position { get; }; // default -1 for "end"
}
interface IPrefixProvider requires IListPage // this definitely needs at least IPage
{
IDictionary<String, String> PrefixCommands { get; }
// event Windows.Foundation.TypedEventHandler<Object, IAddTokenArgs> RequestAddToken;
void UpdateSearch(ISearchUpdateArgs args);
}
```
Instead, here's the `IPrefixProvider` implemented on one page, separate from the command provider.
```cs
class MyPrefixCommandProvider : ICommandProvider
{
private PeopleDynamicListPage _peopleCommand; // com.contoso.people
private CommandsListPage _commandsCommand; // com.contoso.commands
private AddFileCommand _addFileCommand; // com.contoso.addFile
private PrefixSearchPage _prefixSearchPage;
public MyPrefixCommandProvider()
{
_prefixSearchPage = new PrefixSearchPage();
}
public ICommandItem[] GetTopLevelCommands()
{
return new ICommandItem[] { _prefixSearchPage };
}
public ICommand GetCommand(String id)
{
if (id == "com.contoso.people")
{
return _peopleCommand;
}
else if (id == "com.contoso.commands")
{
return _commandsCommand;
}
else if (id == "com.contoso.addFile")
{
return _addFileCommand;
}
return null;
}
}
class PrefixSearchPage : ListPage, IPrefixProvider
{
public IDictionary<String, String> PrefixCommands => new Dictionary<String, String>
{
{ "@", "com.contoso.people" },
{ "/", "com.contoso.commands" },
{ "+", "com.contoso.addFile" },
};
public event Windows.Foundation.TypedEventHandler<Object, ITokenPickedEventArgs> TokenAdded;
public PrefixSearchPage()
{
// Initialize the page...
}
public void OnTokenPicked(ITokenPickedEventArgs args)
{
// Raise the event to notify CmdPal of the picked token
TokenAdded?.Invoke(this, args);
}
public void SendQuery(ISearchUpdateArgs args)
{
// Handle the search update, possibly updating the list of items based on the new search text
var searchText = args.NewSearchText;
var properties = args.GetProperties();
var tokens = properties.TryLookup<object>("tokens") as ITokenPositions[];
// Here you could use these tokens to update the commands in our own search results
// Or just save them, and plumb them into the InvokableCommand the user eventually picks
}
// Other ListPage members...
}
```
--------------------
### flow diagram
```mermaid
sequenceDiagram
autonumber
box host
participant U as User
participant H as Host (DevPal)
end
box extension
participant PP as PrefixSearchPage<br>(IPrefixProvider)
participant PCP as MyPrefixCommandProvider<br>(ICommandProvider)
participant EC as MyDynamicCommand<br>(IListItem)
end
loop setup
H-->>PCP: GetTopLevelCommands()
note left of H: "navigate" to<br>PrefixSearchPage
activate PP
note over H, PP: InitializeProperties from PrefixSearchPage
H->>PP: GetProperties()
note left of H: cache prefix map
H->>PP: GetItems()
end
loop Typing normal characters
U->>H: Key press (char)
H-->>H: UI updates basic text
H->>PP: SendQuery(ISearchUpdateArgs)
PP->>EC: Update text or<br>otherwise handle query
end
loop Handling prefix char
U->>H: Key press '@'
H-->>H: UI updates basic text
H-->>H: See that '@' is a prefix matching<br>'com.contoso.people'
H->>PCP: GetCommand(com.contoso.people)
create participant PDP as PeopleDynamicListPage<br>(IDynamicListPage)
PCP-->PDP: new PeopleDynamicListPage()
deactivate PP
activate PDP
note left of H: "navigate" to<br>PeopleDynamicListPage
note over H, PDP: InitializeProperties from PeopleDynamicListPage
H->>PDP: GetItems()
create participant PC as PeopleCommand
PDP-->PC: new PeopleCommand()
note left of H: Items from <br>PeopleDynamicListPage<br>are now displayed
loop People Page search
U->>H: User types
note right of PDP: Normal Dynamic List page<br>flow here
H-->>H: UI updates basic text
H->>PDP: SearchText.set
PDP->>H: RaiseItemsChanged
H->>PDP: GetItems()
note left of H: User invokes list item
H->>PC: Invoke()
PC-->PCP: TokenPicked()
PCP->>H: RequestAddToken
end
deactivate PDP
activate PP
end
```
* If a user repositions the caret to be before the current prefix, we need to dismiss the "pushed" page
* Everything that the user types after the prefix is sent to the pushed page instead of the original page
* If the user deletes the prefix, we need to dismiss the pushed page and return to the original page
### Thought experiment: Should page be responsible for adding tokens?
Should we be having the `PrefixSearchPage` handle telling us what the current tokens are? Or should CmdPal be tracking that itself?
Like, when the token is picked, should the API be `AddToken` or `SearchTextChanged` with the new text and the list of tokens?
I guess the second form makes it easier for an extension to determine what the contents of the search box are. So they'd raise a
```cs
interface ITokenPositions
{
IToken Token { get; };
Int64 Position { get; }; // default -1 for "end"
}
interface ISearchTextChangedArgs
{
String NewSearchText { get; } // The text that the user has typed into the search box.
ITokenPositions[] CurrentTokens { get; } // The tokens that are currently in the search box.
}
```
But, that's not really what we want either. How would we know where tokens are within the NewSearchText? The extension could set NewSearchText to just garbage that isn't related to the tokens at all.
`ITextRange` is in `Microsoft.UI.Text`. Don't want to have to rely on and WASDK types.
So that kind of necessitates that we have
```cs
interface ISearchRun
{
}
interface IInputTextRun : ISearchRun
{
String Text { get; };
}
interface ITokenRun : ISearchRun
{
IToken Token { get; };
}
interface IReadOnlyTextRun : ISearchRun
{
String Text { get; };
}
interface ISearchTextChangedArgs
{
ISearchRun[] SearchRuns { get; }
}
interface IPrefixProvider requires IListPage // this definitely needs at least IPage
{
IDictionary<String, String> PrefixCommands { get; }
void UpdateSearch(ISearchTextChangedArgs args);
}
// And just use a PropChanged(Page.SearchRuns) to notify of changes -> host
```
Then, when the user picks a suggestion, the page itself would have to:
```cs
public ISearchRun[] SearchRuns => _currentRuns.ToArray();
private IList<ISearchRun> _currentRuns = new List<ISearchRun>();
private void OnTokenPicked(MyTokenPickedEventArgs args)
{
var token = args.Value;
_currentRuns.Add(new TokenRun { Token = token });
_currentRuns.Add(new TextRun(" ")); // add a space after the token
// Raise the event to notify CmdPal of the picked token
RaisePropertyChanged(nameof(SearchRuns));
}
```
This is just that thing where all life evolves to be crabs. It's the token
search spec again.
TODO!
this current design requires that the picked suggestion raise an event up through the page hosting the suggestion, into the prefix page. That necessitates a _lot_ of plumbing. Because the prefix page _doesn't actually have a reference to the suggestion pages_. So we can't wire the events from
command -> suggestion page -> prefix page.
we need to wire
command -> suggestion page -> command provider -> prefix page
which is gross.
How could we make that easier? So that the things that are suggestions can just raise an event straight to the prefix page?
We could just have the prefix page own the suggestion pages, and have the command provider expose the other commands in GetCommand as members of the prefix page.
```cs
public class MyPrefixCommandProvider : ICommandProvider
{
private PrefixSearchPage _prefixSearchPage;
public MyPrefixCommandProvider()
{
_prefixSearchPage = new PrefixSearchPage();
}
public ICommandItem[] GetTopLevelCommands()
{
return new ICommandItem[] { _prefixSearchPage };
}
public ICommand GetCommand(String id)
{
return _prefixSearchPage.GetCommand(id);
}
}
public class PrefixSearchPage : ListPage, IPrefixProvider
{
private PeopleDynamicListPage _peopleCommand; // com.contoso.people
private CommandsListPage _commandsCommand; // com.contoso.commands
private AddFileCommand _addFileCommand; // com.contoso.addFile
public IDictionary<String, String> PrefixCommands => new Dictionary<String, String>
{
{ "@", _peopleCommand.Id },
{ "/", _commandsCommand.Id },
{ "+", _addFileCommand.Id },
};
public event Windows.Foundation.TypedEventHandler<Object, ITokenPickedEventArgs> TokenAdded;
public ISearchRun[] SearchRuns => _currentRuns.ToArray();
private IList<ISearchRun> _currentRuns = new List<ISearchRun>();
public PrefixSearchPage()
{
_peopleCommand = new PeopleDynamicListPage(HandleTokenPicked);
_commandsCommand = new CommandsListPage(HandleTokenPicked);
_addFileCommand = new AddFileCommand(HandleTokenPicked);
// Initialize the page...
}
public ICommand GetCommand(String id)
{
if (id == _peopleCommand.Id) // com.contoso.people
{
return _peopleCommand;
}
else if (id == _commandsCommand.Id) // com.contoso.commands
{
return _commandsCommand;
}
else if (id == _addFileCommand.Id) // com.contoso.addFile
{
return _addFileCommand;
}
return null;
}
public void HandleTokenPicked(ITokenPickedEventArgs args)
{
var token = args.Token;
_currentRuns.Add(new TokenRun { Token = token });
_currentRuns.Add(new TextRun(" ")); // add a space after the token
// Raise the event to notify CmdPal of the picked token
RaisePropertyChanged(nameof(SearchRuns));
}
public void UpdateSearch(ISearchTextChangedArgs args)
{
var searchTokens = args.CurrentTokens;
_currentRuns = searchTokens.ToList();
// For example: you could grab all the text from the runs like this
var newText = "";
foreach (var run in args.SearchRuns)
{
if (run is IInputTextRun inputRun)
{
newText += inputRun.Text;
}
else if (run is ITokenRun tokenRun)
{
newText += tokenRun.Token.DisplayText;
}
else if (run is IReadOnlyTextRun readOnlyRun)
{
newText += readOnlyRun.Text;
}
}
}
// Other ListPage members...
}
```
Then the prefix page can own the suggestion pages, and wire their events directly to itself.
Does this mean that the `ISearchTextChangedArgs` coming into the extension are something instantiated by the host? yea. That would force the extension to safely deal with the args. Unless we definitely had OSS MarshalByValue always, we can't be sure that what the extension sees is safe to handle.
It would be a weird mishmash where the set of tokens is half owned by the extension (tokens) and half owned by the host (text). And I don't know how having a token roundtrip across the ABI both ways would translate. Probably fine.
```cs
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[new InputTextRun("h")]});
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[new InputTextRun("he")]});
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[new InputTextRun("hel")]});
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[new InputTextRun("hell")]});
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[new InputTextRun("hello")]});
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[new InputTextRun("hello ")]});
// user types '@'
// user picks "Alice" from people page
// page itself raises a PropChanged(SearchRuns)
page.UpdateSearch(new SearchTextChangedArgs(){SearchRuns=[
new InputTextRun("hello "), // created by host
new TokenRun(Alice), // created by page
new InputTextRun(" "), // created by page
]});
```
-----------------------
### On the command palette side
```cs
var args = new CmdPalSearchUpdateArgs();
// assume that our CmdPal-side implementation has a SetProperty(string, object) to add an extended attribute
args.SetProperty("tokens", new TokenPositions[]{ new(pos: 0, token: t.Value), ... });
args.SetProperty("correlation", myWhateverValue);
prefixProvider.SendQuery(args);
```
### rejected garbage
This is what it looks like if we have the IPrefixProvider implemented in our command provider.
```cs
class MyPrefixCommandProvider : ICommandProvider, IPrefixProvider
{
private PeopleDynamicListPage _peopleCommand;
private CommandsListPage _commandsCommand;
private AddFileCommand _addFileCommand;
public IDictionary<String, String> PrefixCommands => new Dictionary<String, String>
{
{ "@", "com.contoso.people" },
{ "/", "com.contoso.commands" },
{ "+", "com.contoso.addFile" },
};
public MyPrefixCommandProvider()
{
_peopleCommand = new PeopleDynamicListPage() { TokenPicked = OnTokenPicked };
_commandsCommand = new CommandsListPage() { TokenPicked = OnTokenPicked };
_addFileCommand = new AddFileCommand() { TokenPicked = OnTokenPicked };
}
public event Windows.Foundation.TypedEventHandler<Object, ITokenPickedEventArgs> TokenAdded;
public void OnTokenPicked(ITokenPickedEventArgs args)
{
// Raise the event to notify CmdPal of the picked token
TokenAdded?.Invoke(this, args);
}
// Other ICommandProvider members...
public ICommand GetCommand(String id)
{
if (id == "com.contoso.people")
{
return _peopleCommand;
}
else if (id == "com.contoso.commands")
{
return _commandsCommand;
}
else if (id == "com.contoso.addFile")
{
return _addFileCommand;
}
return null;
}
}
```
This is a little gross in my opinion. The `GetCommand` implementation is close to the list of prefixes, which is nice. But picking the tokens doesn't make sense on the command provider. It should be on the Page.
~~I don't know if this compiles:~~ This DOESN'T compile, a struct field can't be a pointer type.
```cs
struct TokenRun
{
String Text;
Boolean IsReadOnly;
Object Value;
}
```
~~That boxes us into those three properties and never more, but would ensure that we'd always cleanly transit the ABI~~

View File

@@ -0,0 +1,545 @@
---
author: Mike Griese
created on: 2025-08-15
last updated: 2025-09-04
issue id: n/a
---
## Addenda II-A: Rich Search Box
_What if the search box wasn't just a text box, but was an actually rich search
surface?_
This is an idea born by a collection of requests. Essentially, we want to create
a richer input box that can also accept entities inline with text.
What are things we actually want users to be able to do with this?
* Type just plain text.
* Type a string that has a special meaning, like `@` or `#`, and trigger some
suggestions (as provided by the extension)
* `#` might bring up a special kind of string input (which is treated visibly
differently, like a tag)
* `/` might bring up a static list of commands that the user can invoke
* `@` might bring up a dynamic list of users to filter (and those are
dynamically generated as the user types, so we don't return 40000 users)
* There may be a button or other UI element **that the extension controls**
which _also_ allows the user to trigger a picker (e.g. if the extension wants
to manually add a token to the search box)
* If the user backspaces a token, it removes the whole token (except for the
last basic string token, which is just a text box)
Essentially, we want a text box that lets an extension create a richer set of
inputs than just "a string of text". Very much of this _could_ be achieved with
an Adaptive Card, but RichSearch enables this experience to be inline with what
the user is typing. This enables a more natural "conversational" or even
"command-line-like" experience.
What follows is a basic outline for a Rich Search API. This API doesn't
prescribe a UI presentation or framework (similar to the rest of the Command
Palette API). And other than `INotifyPropChanged` and `INotifyItemsChanged`, it
doesn't rely on any other bits of the Command Palette API.
> [!INFO]
>
> This is a draft `.idl` spec. Details are still subject to change. Overall
> concepts however will likely remain similar
```c# rich search
[uuid("a578ed30-1374-4601-97ba-8bd36a0097cd")]
interface IToken requires INotifyPropChanged {};
interface ISearchUpdateArgs requires IExtendedAttributesProvider
{
String NewSearchText { get; } // The text that the user has typed into the search box.
}
// Basic types of tokens:
// * IBasicStringToken: This is your standard text box input token. The text is
// not stylized at all. Essentially, it just represents a run of text in the
// text box, between other tokens.
// * ILabelToken: This is just a static run of text, with no user interaction.
// This isn't an input field - it is just static text between other tokens.
interface IBasicStringToken requires IToken
{
String Text { get; }
void UpdateText(ISearchUpdateArgs newText); // This is the setter for the text, which will be used by the host to update the text in the search box.
}
interface ILabelToken requires IToken
{
// This is just a static run of text, with no user interaction. It can't accept "focus"
String Text { get; };
}
// The following are all tokens of richer input. They can each have their own
// behaviors for picking a value. The common interface for these tokens is
// ICommandArgument. ICommandArgument provides a common set of properties for
// expressing the appearance of the token (icon and display name) and a stored
// value (whatever that value might be).
interface ICommandArgument requires IToken
{
IIconInfo Icon { get; };
String DisplayName { get; };
Object Value { get; }; // No setter. Each individual token type will have its own setter.
}
// * IStringInputToken: A separate text input field. Hosts should give this a
// different UX treatment, as if to indicate it is separate input from the
// rest of the search text.
interface IStringInputToken requires ICommandArgument
{
String Text { get; set; } // This is basically just the `.Value`, but with a setter
}
// * ICustomHwndPickerToken: A token that allows the extension to display its
// own picker UI. The extension can use the hostHwnd to show the picker
// relative to the host window. The extension is responsible for storing its own .Value.
interface ICustomHwndPickerToken requires ICommandArgument
{
void ShowPicker(UInt64 hostHwnd); // extension is responsible for setting your own .Value
}
// * IPickerToken: A single value in a picker (below)
interface IPickerToken requires ICommandArgument
{
String Title { get; }
String Subtitle { get; }
}
// * IStaticPickerToken: A token that displays a static list of options for the
// user to choose from. If you squint, this is just like a IListPage.
interface IStaticPickerToken requires ICommandArgument, INotifyItemsChanged
{
IPickerToken[] GetItems();
IPickerToken SelectedItem { get; set; }
// .Value should effectively be SelectedItem?.Value
// TODO: Loading, HasMore, LoadMore?
}
// * IDynamicPickerToken: A token that displays a list of options which can be
// dynamically updated as the user types into the token. Especially good for
// lists where the entire set of possible values cannot be reasonably
// enumerated
interface IDynamicPickerToken requires IStaticPickerToken
{
String SearchText { get; set; }
}
interface IRichSearch requires INotifyPropChanged
{
IToken[] SearchTokens{ get; };
void RemoveToken(IToken token);
}
```
### Examples
Below we have a simple sample for how someone might implement a rich search
experience using the interfaces defined above.
In this example, we'll create a simple implementation of `IRichSearch` that
allows the user to type `@` to trigger a picker. That picker is then displayed
to the user, allowing them to select an entity. As the user types in that
picker, the extension will be told, and we'll be able to update the contents of
the picker to match.
<table>
<tr>
<td>
Some standard base classes for `INotifyPropChanged` and `INotifyItemsChanged`,
to make it easier for developers to implement these interfaces.
```cs
public partial class BaseObservable : INotifyPropChanged
{
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
protected void OnPropertyChanged(string propertyName) =>
try { PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName)); } catch { }
}
public partial class BaseObservableList : BaseObservable, INotifyItemsChanged
{
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
protected void OnItemsChanged(IItemsChangedEventArgs args) =>
try { ItemsChanged?.Invoke(this, args); } catch { }
}
```
`M365RichSearch` is our sample Rich Search implementation. It starts with a
single basic string token. When the user types, we immediately begin by sending
input to that first `BasicStringToken`.
As the user types, we look to see if they if they typed a special character
(like `@`). If they do, we add a new token to the list. When we return the new
token back to the host, the host will "move focus" into the new token. When the
user selects an item from the picker, we add another basic string token after
it.
```cs
class M365RichSearch : BaseObservable, IRichSearch
{
public IToken[] SearchTokens {
get => _searchTokens;
set {
if (_searchTokens != value) {
UpdateSearchTokens(_searchTokens, value);
OnPropertyChanged(nameof(SearchTokens));
}
}
}
public M365RichSearch() {
// Initialize with a default token, e.g. a text input token
var baseStringInput = new BasicStringToken();
baseStringInput.PropChanged += OnStringTokenChanged;
SearchTokens = [baseStringInput];
}
private IToken[] _searchTokens = [];
private void UpdateSearchTokens(IToken[] oldTokens, IToken[] newTokens) {
// probably need to add/remove event handlers on the tokens
_searchTokens = newTokens;
}
private void OnStringTokenChanged(object sender, PropChangedEventArgs args) {
if (args.PropertyName == nameof(BasicStringToken.Text)) {
OnStringTextChanged(sender, ((BasicStringToken)sender).Text);
}
}
private void OnStringTextChanged(object sender, string newText) {
if (sender != SearchTokens.Last()) {
// User typed in a non-last token, ignore it.
return;
}
var searchToken = ((BasicStringToken)sender);
if (newText.Length > 0 && newText.EndsWith('@')) {
// User typed '@', let's add a M365EntityPickerToken
// Create the new picker token
var entityPickerToken = new M365EntityPickerToken();
entityPickerToken.PropChanged += OnEntityPickerChanged;
SearchTokens = SearchTokens.Append(entityPickerToken).ToArray();
// The new token "owns" the '@', so we need to remove it from the basic string
searchToken.SilentUpdateText(newText.TrimEnd('@'));
OnPropertyChanged(nameof(SearchTokens));
}
else if (newText.Length > 0 && newText.EndsWith('#')) { /* etc, etc */}
else if (newText.Length > 0 && newText.EndsWith('/')) { /* etc, etc */}
}
private void OnEntityPickerChanged(object sender, PropChangedEventArgs args) {
if (args.PropertyName == nameof(M365EntityPickerToken.SelectedItem)) {
// User selected an entity, we now want to add another BasicStringToken after it
var baseStringInput = new BasicStringToken(" ");
baseStringInput.PropChanged += OnStringTokenChanged;
SearchTokens = SearchTokens.Append(baseStringInput).ToArray();
OnPropertyChanged(nameof(SearchTokens));
}
}
}
```
</td>
<td>
Below, `BasicStringToken` is a toolkit class for just the basic string token.
The basic string token just represents text in the search box, with no special
meaning. You can imagine that a basic page essentially just has one long
`BasicStringToken` for its search text.
```cs
class BasicStringToken : BaseObservable, IBasicStringToken {
public string Text {
get => _text;
set {
if (_text != value) {
var oldValue = _text;
_text = value;
OnPropertyChanged(nameof(Text));
}
}
}
private string _text;
public BasicStringToken(string initialText = "") {
_text = initialText;
}
public void UpdateText(ISearchUpdateArgs newText) {
Text = newText.Text;
// newText may have other properties too, like a correlation vector,
// but that's not demo'd here.
}
// helper for setting the text without raising a property changed event
public void SilentUpdateText(string newText) {
_text = newText;
}
}
```
This is a more complex example: a dynamic picker token. When this is added to
the list of tokens, the host will "move focus" to that token. The host will
immediately call `GetItems()` on the token, which will return a list of items to
show the user.
As the user types, the host will call `SearchText.set` on the token. In our
sample here, we use that to query the backend (in `UpdateSearchText`), then call
`RaisePropertyChanged` on `SearchText` to notify the host that the search text
has changed.
When the user selects an item, the host will set the `SelectedItem` property on
the token.
If the user backspaces the last character in the token (s.t. `SearchText` is
empty), the host will tell the `IRichSearch` to remove the token from the list
of tokens.
```cs
class M365EntityPickerToken : BaseObservable, IDynamicPickerToken {
public IIconInfo? Icon => _selected?.Icon;
public string? DisplayName => _selected?.DisplayName;
public object? Value => _selected?.Value;
public string SearchText {
get => _searchText;
set {
if (_searchText != value) {
var oldValue = _searchText;
_searchText = value;
UpdateSearchText(oldValue, value);
OnPropertyChanged(nameof(SearchText));
}
}
};
public IPickerToken? SelectedItem {
get => _selected;
set {
if (_selected != value) {
_selected = value;
OnPropertyChanged(nameof(SelectedItem));
OnPropertyChanged(nameof(Icon));
OnPropertyChanged(nameof(DisplayName));
}
}
}
public M365EntityPickerToken() {
UpdateSearchText(null, string.Empty);
}
public IPickerToken[] GetItems() {
// return a list of M365 entities based on the search text
return _items;
}
private string _searchText = "@";
private IPickerToken[] _items = [];
private IPickerToken? _selected = null;
private void UpdateSearchText(string? oldValue, string newValue) {
// Call out to M365 APIs to get the entities based on the search text
// and update the list of items.
_items = M365Api.GetEntities(newValue); // Pretend this is it.
RaiseItemsChanged();
}
}
```
(omitted from this sample: cancellation tokens to only have one query at a time, loading states, error states, etc.)
</td>
</tr>
</table>
```mermaid
sequenceDiagram
autonumber
box host
participant U as User
participant H as Host (DevPal)
end
box extension
participant RS as M365RichSearch (IRichSearch)
participant BST as BasicStringToken
end
activate BST
loop Typing normal characters
U->>H: Key press (char)
H-->>H: UI updates basic text
H->>BST: UpdateText(ISearchUpdateArgs)
BST-->>RS: PropChanged(Text)
activate RS
RS->>RS: OnStringTextChanged()<br/>(not ending with trigger)
deactivate RS
end
U->>H: Key press '@'
H->>BST: UpdateText(ISearchUpdateArgs "hello@")
BST-->>RS: PropChanged(Text)
activate RS
RS->>RS: OnStringTokenChanged()
RS->>RS: OnStringTextChanged() (endsWith '@')
RS->>BST: SilentUpdateText("hello")
create participant PT as M365EntityPickerToken
RS->>PT: Create M365EntityPickerToken
note right of PT: (this is in the extension<br>mermaid just won't let me group it)
RS->>PT: (subscribe PropChanged)
RS->>RS: Append picker token to SearchTokens
RS-->>H: PropChanged(SearchTokens)
deactivate RS
H->>H: Replace token list in UI
H->>PT: Focus + GetItems()
deactivate BST
activate PT
PT-->>H: Initial items (likely empty or placeholder)
note over PT,U: Dynamic picker now active
loop User types in dynamic picker
U->>H: Key press (char)
H->>PT: set SearchText(new value)
PT->>PT: UpdateSearchText()
create participant API as M365Api
PT->>API: GetEntities(query)
%% destroy API
API-->>PT: Entities[]
PT->>H: ItemsChanged (new items)
H-->>U: Refresh picker list
end
U->>H: User presses enter/clicks <br> to select entity
H->>PT: set SelectedItem(entity)
PT-->>RS: PropChanged(SelectedItem, Icon, DisplayName)
RS->>RS: OnEntityPickerChanged()
RS->>RS: Create new BasicStringToken(" ")
RS->>BST: (subscribe PropChanged)
RS->>RS: Append new BasicStringToken
RS-->>H: PropChanged(SearchTokens)
deactivate PT
H->>H: Update UI & focus new BasicStringToken
H-->>U: Ready for further typing
%% alt User backspaces empty dynamic picker
%% H->>RS: RemoveToken(PT)
%% RS->>RS: UpdateSearchTokens()
%% RS-->>H: PropChanged(SearchTokens)
%% H->>BST: Focus previous BasicStringToken
%% end
```
### Commands with parameters
We've also long experimented with the idea of commands having parameters that
can be filled in by the user. These would be commands that take a couple
lightweight inputs, so that the use can input them more natively than an
adaptive card.
Previous drafts included a new type of `ICommand` ala
`IInvokableWithParameters`. However, these ran into edge cases:
* Where are the parameters displayed? In the search box? On the item?
* What happens if a context menu command needs parameters?
* Does the _page_ have parameters?
none of which were trivially solvable by having the parameters on the command.
Instead, we can leverage the concept of a "rich search" experience to provide
that lightweight parameter input method.
We'll add a new type of page, called a `RichSearchPage`. It is a list page, but
with a rich search box at the top.
```c#
interface IRichSearchPage requires IListPage {
IRichSearch Search { get; };
};
```
This lets the user activate the command that needs parameters, and go straight
into the rich input page. That page will act like it is _just_ the command the
user "invoked", and will let us display additional inputs to the user.
#### Parameters Example
Now, lets say you had a command like "Create a note \${title} in \${folder}".
`title` is a string input, and `folder` is a static list of folders.
The extension author can then define a `RichSearchPage` with a `IRichSearch`
that has four tokens in it:
* A `ILabelToken` for "Create a note"
* A `IStringInputToken` for the `title`
* A `ILabelToken` for "in"
* A `IStaticListToken` for the `folder`, where the items are possible folders
Then, when the user hits <kbd>↲</kbd>, we gather up all the tokens, and we can
reference them in the command. As an example, here's the `CreateNoteCommand`,
which implements the `IRichSearchPage` interface:
The list page can always change its results based on the user's input. In our
case, we'll listen for the value of the last token to be set. When it is, we can
then display the final list item with our fully formed command for the user to
invoke.
```csharp
class CreateNoteCommand : IRichSearchPage {
public string Name => "Create a note";
public string Id => "create_note";
public IIconInfo Icon => null;
public IRichSearch Search { get; }
private StringInputToken _titleToken;
private NotesFolderToken _folderToken;
private IListItem? _createNoteItem;
public CreateNoteCommand() {
Search = new RichSearch();
_titleToken = new StringInputToken("title", "Title of the note");
_folderToken = new NotesFolderToken("folder", "Select a folder");
Search.SearchTokens = [
new LabelToken("Create a note"),
_titleToken,
new LabelToken("in"),
_folderToken
];
_folderToken.OnSelectedItemChanged += (sender, e) => {
// Update the command with the selected folder
UpdateCommand();
};
}
private void UpdateCommand() {
_createNoteItem = new ListItem() {
Title = _titleToken.Value,
Subtitle = _folderToken.SelectedItem?.Title,
Command = new CreateNoteCommand(title: _titleToken.Value, folder: _folderToken.SelectedItem.Value)
};
}
}
```
### Miscellaneous notes
We shouldn't put a `set`ter on IRichSearch::SearchTokens. That would allow the
host to instantiate ITokens and give them to the extension. The extension
shouldn't have to lifetime manage the host's objects.
It would be really great if we could have the setting of the value of the last
token "commit" the whole command, and let the user invoke the command
immediately when picking a value. Not sure if that's reasonable though.
`IRichSearchPage` probably needs a way to go back, that's not driven by user input. Or not driven by backspacing tokens. For example, in our previous `CreateNoteCommand` example, if the user backspaces from:
* The filled `folder` param: the extension will get a `RemoveToken` call for the
`folder` token, which we'll use to clear its value, and trigger focus to move
back into it.
* The empty `folder` param: we'll move focus back to the `title` token.
* the filled `title` param: we'll backspace a character.
* the empty `title` param: **TODO! WHAT DO WE DO HERE?**

View File

@@ -1,7 +1,7 @@
---
author: Mike Griese
created on: 2024-07-19
last updated: 2025-08-08
last updated: 2025-11-21
issue id: n/a
---
@@ -75,6 +75,14 @@ functionality.
- [Advanced scenarios](#advanced-scenarios)
- [Status messages](#status-messages)
- [Rendering of ICommandItems in Lists and Menus](#rendering-of-icommanditems-in-lists-and-menus)
- [Addenda I: API additions (ICommandProvider2)](#addenda-i-api-additions-icommandprovider2)
- [Addenda II: Commands with Parameters](#addenda-ii-commands-with-parameters)
- [String parameters](#string-parameters)
- [Command parameters - Invokable Commands](#command-parameters---invokable-commands)
- [Command parameters - List Commands](#command-parameters---list-commands)
- [Examples](#examples)
- [Addenda III: Rich Search (DRAFT)](#addenda-iii-rich-search-draft)
- [Nov 2025 status](#nov-2025-status)
- [Class diagram](#class-diagram)
- [Future considerations](#future-considerations)
- [Arbitrary parameters and arguments](#arbitrary-parameters-and-arguments)
@@ -2046,6 +2054,183 @@ Fortunately, we can put all of that (`GetApiExtensionStubs`,
developers won't have to do anything. The toolkit will just do the right thing
for them.
## Addenda II: Commands with Parameters
Extensions will often want to provide commands that accept parameters from the
user.
To support this, we're adding a new page type. The `IParametersPage` is a page
that allows an extension to define a set of parameters that the user can fill.
These parameters can be of different types, such as:
* Labels: static text that provides context or instructions.
* String parameters: text input fields where the user can type a string.
* Command parameters: interactive fields that allow the user to select from a
list of predefined commands, or just press a button to select an input.
Interleaving labels with parameters allows extensions to create rich, guided
input forms for their commands. These are a more lightweight solution than the
current adaptive card content.
```csharp
[uuid("a2590cc9-510c-4af7-b562-a6b56fe37f55")]
interface IParameterRun requires INotifyPropChanged
{
};
interface ILabelRun requires IParameterRun
{
String Text{ get; };
};
interface IParameterValueRun requires IParameterRun
{
String PlaceholderText{ get; };
Boolean NeedsValue{ get; }; // TODO! name is weird
};
interface IStringParameterRun requires IParameterValueRun
{
String Text{ get; set; };
// TODO! do we need a way to validate string inputs?
};
interface ICommandParameterRun requires IParameterValueRun
{
String DisplayText{ get; };
ICommand GetSelectValueCommand(UInt64 hostHwnd);
IIconInfo Icon{ get; }; // ? maybe
};
interface IParametersPage requires IPage
{
IParameterRun[] Parameters{ get; };
IListItem Command{ get; };
};
```
When we open a `IParametersPage`, we will render the `Parameters` in the search
box. We'll move focus to the first `IParameterRun` that is not a `ILabelRun`.
What those interactions looks like depends on the type of `IParameterRun`.
There are three basic types of inputs: strings, invokable commands, and lists.
Strings are a special case that doesn't require a command to set the value.
Lists and invokable commands are picked based on the type of the
`SelectValueCommand`. Each of these are detailed below.
When all the parameters have `NeedsValue` set to `false`, we will display a
single item to the user - the `Command` item.
### String parameters
These are rendered as a text box within the search box. The user can type into
it. Focus is moved to the next parameter when the user presses Enter or tab.
### Command parameters - Invokable Commands
These are used when the `SelectValueCommand` is an `IInvokableCommand`.
These are rendered as a button within the search box. The button text is
`DisplayText` if it is set. If it is not, we will display the
`PlaceholderText`. If the user clicks the button, we invoke the
`SelectValueCommand` (and ignore the `CommandResult`).
This is good for file pickers, date pickers, color pickers, etc. Anything that
requires a custom UI to pick a value.
When the extension has picked a value, it should set the `NeedsValue` to false.
The extension can also set the `DisplayText` and `Icon` to reflect the chosen value.
When the user presses enter with the button focused, we will also invoke the
`SelectValueCommand`.
When the user presses tab, we will move focus to the next parameter.
If the `NeedsValue` property is changed to `false` while it's focused, we will
move focus to the next parameter.
### Command parameters - List Commands
These are used when the `SelectValueCommand` is an `IListPage` - both static and
dynamic lists work similarly.
These are rendered as a text box within the search box. When the user focuses
the text box, we will display the items from the `IListPage` in the body of
CmdPal. The user can then type to filter the list. This filtering will work the
same way as any other list page in CmdPal - CmdPal will filter static lists, or
pass the query to a dynamic list.
The items in this list should all be `IListItem` objects with
`IInvokableCommands`. Putting a `IPage` into one of these items will cause the
user to navigate away from the parameters page, which would probably be
unexpected.
When the user picks an item from the list, the extension should handle that
command by bubbling an event up to the `CommandRun`, and setting the `Value`,
`DisplayText`, and `Icon` properties, and setting `NeedsValue` to false.
When the user presses enter with the text box focused, we will invoke the
command of the selected item in the list.
When the user presses tab, we will move focus to the next parameter.
If the `NeedsValue` property is changed to `false` while it's focused, we will
move focus to the next parameter.
### Examples
Lets say you had a command like "Create a note \${title} in \${folder}".
`title` is a string input, and `folder` is a static list of folders.
The extension author can then define a `IParametersPage` with four runs in it:
* A `ILabelRun` for "Create a note"
* A `IStringParameterRun` for the `title`
* A `ILabelRun` for "in"
* A `ICommandParameterRun` for the `folder`. The `Command` will be a
`IListPage`, where the items are possible folders
In this example, the user can pick the "create note" command, then type the
title, hit enter/tab, and then pick a folder from the list, then hit enter to
run the command.
Samples for the parameters page are implemented over in
[the sample extension](../../ext/SamplePagesExtension/Pages/ParameterSamples.cs)
## Addenda III: Rich Search (DRAFT)
> [!NOTE]
> _Mike_: Rich search and parameters were prototyped together, but ultimately we used two different solutions.
>
> Currently, we have a dummy implementation of draft C (ZWSP tokens), but without full API changes. Detailed [below](#nov-2025-status).
Extensions will often want to provide rich search experiences for their users.
This addenda is broken into multiple draft specs currently. These represent
different approaches to the same goals.
* **A**: [Rich Search Box](./drafts/RichSearchBox-draft-A.md)
* **B**: [Prefix Search](./drafts/PrefixSearch-draft-B.md)
* **C**: [ZWSP tokens](./drafts/PlainRichSearch-draft-C.md)
### Nov 2025 status
As of Nov 2025, we're implementing a simple version of draft C in the host.
In this version, if the extension implements `IDynamicListPage`, and also
implements `IExtendedAttributesProvider`, then they can set the `TokenSearch`
property. This will enlighten CmdPal to treat ZWSP-separated tokens in the
search text specially.
For an example, see
[this sample implementation](../../ext/SamplePagesExtension/Pages/SampleSuggestionsPage.cs).
In my head, I am still leaning towards a more full-featured version of draft C,
but with full CommandItem's in the `ISearchUpdateArgs` instead of just strings.
We'd almost need a new page type to support that, where the extension can add
`ICommandItem`s to the search box directly.
## Class diagram
This is a diagram attempting to show the relationships between the various types we've defined for the SDK. Some elements are omitted for clarity. (Notably, `IconData` and `IPropChanged`, which are used in many places.)

View File

@@ -0,0 +1,38 @@
// 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.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using ManagedCsWin32;
using WinRT;
namespace Microsoft.CmdPal.Ext.Actions;
internal static class ActionRuntimeFactory
{
private const string ActionRuntimeClsidStr = "C36FEF7E-35F3-4192-9F2C-AF1FD425FB85";
// typeof(Windows.AI.Actions.IActionRuntime).GUID
private static readonly Guid IActionRuntimeIID = Guid.Parse("206EFA2C-C909-508A-B4B0-9482BE96DB9C");
public static unsafe global::Windows.AI.Actions.ActionRuntime CreateActionRuntime()
{
IntPtr abiPtr = default;
try
{
var classId = Guid.Parse(ActionRuntimeClsidStr);
var iid = IActionRuntimeIID;
var hresult = Ole32.CoCreateInstance(ref Unsafe.AsRef(in classId), IntPtr.Zero, CLSCTX.LocalServer, ref iid, out abiPtr);
Marshal.ThrowExceptionForHR(hresult);
return MarshalInterface<global::Windows.AI.Actions.ActionRuntime>.FromAbi(abiPtr);
}
finally
{
MarshalInspectable<object>.DisposeAbi(abiPtr);
}
}
}

View File

@@ -0,0 +1,45 @@
// 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.Threading.Tasks;
using Windows.AI.Actions;
namespace Microsoft.CmdPal.Ext.Actions;
public static class ActionRuntimeManager
{
private static readonly Lazy<Task<ActionRuntime?>> _lazyRuntime = new(InitializeAsync);
public static Task<ActionRuntime?> InstanceAsync => _lazyRuntime.Value;
private static async Task<ActionRuntime?> InitializeAsync()
{
// If we tried 3 times and failed, should we think the action runtime is not working?
// then we should not use it anymore.
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
var runtime = ActionRuntimeFactory.CreateActionRuntime();
await Task.Delay(500);
return runtime;
}
catch (Exception)
{
// Logger.LogError($"Attempt {attempt} to initialize ActionRuntime failed: {ex.Message}");
// if (attempt == maxAttempts)
// {
// Logger.LogError($"Failed to initialize ActionRuntime: {ex.Message}");
// }
}
}
return null;
}
}

View File

@@ -0,0 +1,60 @@
// 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.Collections.Generic;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.AI.Actions;
using Windows.Foundation.Metadata;
namespace Microsoft.CmdPal.Ext.Actions;
public partial class ActionsCommandsProvider : CommandProvider
{
private readonly List<ICommandItem> _commands;
private static ActionRuntime? _actionRuntime;
private bool _init;
public ActionsCommandsProvider()
{
DisplayName = "Windows Actions Framework";
Icon = IconHelpers.FromRelativePath("Assets\\Actions.png");
Id = "Actions";
_commands = [];
}
public override ICommandItem[] TopLevelCommands()
{
if (!_init)
{
_init = true;
if (ApiInformation.IsApiContractPresent("Windows.AI.Actions.ActionsContract", 4))
{
_actionRuntime = ActionRuntimeManager.InstanceAsync.GetAwaiter().GetResult();
if (_actionRuntime != null)
{
_commands.Add(new CommandItem(new ActionsTestPage(_actionRuntime))
{
Title = "Actions",
Subtitle = "Windows Actions Framework",
Icon = Icons.ActionsPng,
});
}
}
}
return _commands.ToArray();
}
public static readonly bool IsActionsFeatureEnabled = GetFeatureFlag();
private static bool GetFeatureFlag()
{
var env = System.Environment.GetEnvironmentVariable("CMDPAL_ENABLE_ACTIONS_LIST");
return !string.IsNullOrEmpty(env) &&
(env == "1" || env.Equals("true", System.StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,34 @@
// 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.Runtime.InteropServices;
using System.Threading;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Ext.Actions;
[Guid("e87f18cd-985d-4bfc-aacc-117ef57a675d")]
public sealed partial class ActionsTest : IExtension, IDisposable
{
private readonly ManualResetEvent _extensionDisposedEvent;
private readonly ActionsCommandsProvider _provider = new();
public ActionsTest(ManualResetEvent extensionDisposedEvent)
{
this._extensionDisposedEvent = extensionDisposedEvent;
}
public object? GetProvider(ProviderType providerType)
{
return providerType switch
{
ProviderType.Commands => _provider,
_ => null,
};
}
public void Dispose() => this._extensionDisposedEvent.Set();
}

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,29 @@
// 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.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Actions;
internal sealed class Icons
{
internal static IconInfo ActionsPng { get; } = IconHelpers.FromRelativePath("Assets\\Actions.png");
// Action input icons
internal static IconInfo DocumentInput { get; } = new IconInfo("Assets\\Document.png");
internal static IconInfo FileInput { get; } = new IconInfo("\uE8A5");
internal static IconInfo PhotoInput { get; } = new IconInfo("\uE91b");
internal static IconInfo TextInput { get; } = new IconInfo("\uE710");
internal static IconInfo StreamingTextInput { get; } = new IconInfo("\uE710");
internal static IconInfo RemoteFileInput { get; } = new IconInfo("\uE8E5");
internal static IconInfo TableInput { get; } = new IconInfo("\uf575");
internal static IconInfo ContactInput { get; } = new IconInfo("\uE77b");
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<RootNamespace>Microsoft.CmdPal.Ext.Actions</RootNamespace>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>Microsoft.CmdPal.Ext.Actions.pri</ProjectPriFileName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DependentUpon>Resources.resx</DependentUpon>
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
</ItemGroup>
<ItemGroup>
<None Remove="Assets\Actions.png" />
</ItemGroup>
<ItemGroup>
<Content Update="Assets\Actions.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,302 @@
// 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.Tasks;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.AI.Actions;
using Windows.AI.Actions.Hosting;
using Windows.ApplicationModel.Contacts;
using Windows.Storage;
using Windows.Storage.Pickers;
namespace Microsoft.CmdPal.Ext.Actions;
#pragma warning disable SA1402 // File may only contain a single type
#pragma warning disable SA1649 // File name should match first type name
internal sealed partial class ActionsTestPage : ListPage
{
private readonly ActionRuntime _actionRuntime;
public ActionsTestPage(ActionRuntime actionRuntime)
{
_actionRuntime = actionRuntime;
Icon = Icons.ActionsPng;
Title = "Actions";
Name = "Open";
}
public override IListItem[] GetItems()
{
var actions = _actionRuntime.ActionCatalog.GetAllActions();
var items = new List<ListItem>();
var actionsDebug = string.Empty;
foreach (var action in actions)
{
var overloads = action.GetOverloads();
var overloadsTxt = string.Join("\n", overloads.Select(o => $" - {o.DescriptionTemplate}"));
actionsDebug += $"* `{action.Id}`: {action.Description}\n{overloadsTxt}\n";
}
Logger.LogDebug(actionsDebug);
foreach (var action in actions)
{
var overloads = action.GetOverloads();
foreach (var overload in action.GetOverloads())
{
try
{
var inputs = overload.GetInputs();
var tags = inputs.AsEnumerable().Select(input => new Tag(input.Name) { Icon = GetIconForInput(input)! });
if (action.UsesGenerativeAI)
{
tags = tags.Prepend(RobotTag);
}
var command = new DoActionPage(action, overload, _actionRuntime);
items.Add(new ListItem(command)
{
Title = action.Description,
Subtitle = overload.DescriptionTemplate,
Tags = tags.ToArray(),
});
}
catch (Exception)
{
ExtensionHost.LogMessage($"Unsupported action {overload.DescriptionTemplate}");
}
}
}
return items.ToArray();
}
private static IconInfo? GetIconForInput(ActionEntityRegistrationInfo input)
{
return input.Kind switch
{
ActionEntityKind.None => null,
ActionEntityKind.Document => Icons.DocumentInput,
ActionEntityKind.File => Icons.FileInput,
ActionEntityKind.Photo => Icons.PhotoInput,
ActionEntityKind.Text => Icons.TextInput,
ActionEntityKind.StreamingText => Icons.StreamingTextInput,
ActionEntityKind.RemoteFile => Icons.RemoteFileInput,
ActionEntityKind.Table => Icons.TableInput,
ActionEntityKind.Contact => Icons.ContactInput,
_ => null,
};
}
private static readonly IconInfo RobotIcon = new("\uE99A");
private static readonly Tag RobotTag = new() { Icon = RobotIcon };
}
public partial class DoActionPage : ParametersPage
{
public override IconInfo Icon => _command.Icon;
private readonly ActionDefinition _action;
private readonly ActionOverload _overload;
private readonly ActionRuntime _actionRuntime;
private readonly string _id;
private readonly List<IParameterRun> _parameters = new();
private readonly DoActionCommand _command;
private Dictionary<string, ParameterValueRun> _actionParams = new();
public DoActionPage(ActionDefinition action, ActionOverload overload, ActionRuntime actionRuntime)
{
_action = action;
_overload = overload;
_actionRuntime = actionRuntime;
_id = action.Id;
_parameters.Add(new LabelRun(overload.DescriptionTemplate));
var inputs = action.GetInputs();
foreach (var input in inputs)
{
var param = GetParameter(input);
_parameters.Add(param);
if (param is ParameterValueRun p)
{
_actionParams.Add(input.Name, p);
}
}
_command = new DoActionCommand(action, overload, actionRuntime, _actionParams);
}
public override IListItem Command => new ListItem(_command) { Title = _overload.DescriptionTemplate };
public override IParameterRun[] Parameters => _parameters.ToArray();
internal static IParameterRun GetParameter(ActionEntityRegistrationInfo input)
{
IParameterRun param = input.Kind switch
{
ActionEntityKind.None => new LabelRun(input.Name),
ActionEntityKind.Document => new FilePickerParameterRun() { PlaceholderText = input.Name },
ActionEntityKind.File => new FilePickerParameterRun() { PlaceholderText = input.Name },
ActionEntityKind.Photo => new PhotoFilePicker() { PlaceholderText = input.Name },
ActionEntityKind.Text => new StringParameterRun(input.Name),
// ActionEntityKind.StreamingText => new CommandParameter(input.Name, input.Required, ParameterType.StreamingText),
// ActionEntityKind.RemoteFile => new CommandParameter(input.Name, input.Required, ParameterType.RemoteFile),
// ActionEntityKind.Table => new CommandParameter(input.Name, input.Required, ParameterType.Table),
ActionEntityKind.Contact => new StringParameterRun(input.Name),
_ => throw new NotSupportedException($"Unsupported action entity kind: {input.Kind}"),
};
return param;
}
}
public partial class DoActionCommand : InvokableCommand
{
public override string Name => "Invoke";
public override IconInfo Icon => new(_action.IconFullPath);
private readonly ActionDefinition _action;
private readonly ActionOverload _overload;
private readonly ActionRuntime _actionRuntime;
private readonly string _id;
private readonly Dictionary<string, ParameterValueRun> _actionParams;
public override ICommandResult Invoke()
{
// First, check that all required parameters have values.
foreach (var input in _overload.GetInputs())
{
if (_actionParams.TryGetValue(input.Name, out var param))
{
if (param == null || param.NeedsValue)
{
var error = new ToastStatusMessage($"Parameter '{input.Name}' is required.");
error.Show();
return CommandResult.KeepOpen();
}
}
}
_ = Task.Run(InvokeActionAsync);
return CommandResult.Dismiss();
}
private async Task InvokeActionAsync()
{
try
{
var c = _actionRuntime.CreateInvocationContext(actionId: _id);
var f = _actionRuntime.EntityFactory;
var inputs = _overload.GetInputs();
for (var i = 0; i < inputs.Length; i++)
{
var input = inputs[i];
var name = input.Name;
if (_actionParams.TryGetValue(name, out var v))
{
var value = v.Value;
var entity = CreateEntity(input, f, v.Value!);
c.SetInputEntity(name, entity);
}
}
var task = _overload.InvokeAsync(c);
await task;
var statusType = c.Result switch
{
ActionInvocationResult.Success => MessageState.Success,
_ => MessageState.Error,
};
var text = c.Result switch
{
ActionInvocationResult.Success => $"{c.Result.ToString()}",
_ => $"{c.Result.ToString()}: {c.ExtendedError}",
};
var resultToast = new ToastStatusMessage(new StatusMessage() { Message = text, State = statusType });
resultToast.Show();
}
catch (Exception ex)
{
var errorToast = new ToastStatusMessage(new StatusMessage() { Message = ex.Message, State = MessageState.Error });
errorToast.Show();
}
}
public DoActionCommand(ActionDefinition action, ActionOverload overload, ActionRuntime actionRuntime, Dictionary<string, ParameterValueRun> parameters)
{
_overload = overload;
_action = action;
_actionRuntime = actionRuntime;
_id = action.Id;
_actionParams = parameters;
}
private static ActionEntity CreateEntity(ActionEntityRegistrationInfo i, ActionEntityFactory f, object value)
{
var input = value switch
{
string s => s,
StorageFile file => file.Path,
_ => null,
};
if (input == null)
{
throw new NotSupportedException($"Unexpected action input {value.ToString()}");
}
ActionEntity v = i.Kind switch
{
ActionEntityKind.Photo => f.CreatePhotoEntity(input),
ActionEntityKind.Document => f.CreateDocumentEntity(input),
ActionEntityKind.File => f.CreateFileEntity(input),
ActionEntityKind.Text => f.CreateTextEntity(input),
ActionEntityKind.Contact => CreateContact(input, f),
_ => throw new NotSupportedException($"Unsupported entity kind: {i.Kind}"),
};
return v;
}
private static ContactActionEntity CreateContact(string? text, ActionEntityFactory f)
{
var contact = new Contact();
var email = new ContactEmail();
email.Address = text ?? string.Empty;
contact.Emails.Add(email);
return f.CreateContactEntity(contact);
}
}
internal sealed partial class PhotoFilePicker : FilePickerParameterRun
{
protected override void ConfigureFilePicker(object? sender, FileOpenPicker picker)
{
picker.SuggestedStartLocation = PickerLocationId.PicturesLibrary;
picker.FileTypeFilter.Add(".jpg");
picker.FileTypeFilter.Add(".jpeg");
picker.FileTypeFilter.Add(".png");
picker.FileTypeFilter.Add(".gif");
picker.FileTypeFilter.Add(".bmp");
picker.FileTypeFilter.Add(".tiff");
picker.FileTypeFilter.Add(".webp");
}
}
#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type
#nullable disable

View File

@@ -36,12 +36,16 @@
<Content Update="Assets\FileExplorer.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="Assets\Actions.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="Assets\FileExplorer.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Don't include the actions icon - it gets into our package via the actions extension -->
<!--
<Content Update="Assets\Actions.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
-->
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,340 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace SamplePagesExtension.Pages;
#pragma warning disable SA1402 // File may only contain a single type
#pragma warning disable SA1649 // File name should match first type name
#nullable enable
public sealed partial class SimpleParameterTest : ParametersPage
{
private readonly StringParameterRun _stringParameter;
private readonly InvokableCommand _command;
public SimpleParameterTest()
{
Name = "Open";
Icon = new("\uE933");
_stringParameter = new StringParameterRun()
{
PlaceholderText = "Type something",
};
_command = new AnonymousCommand(() =>
{
var input = _stringParameter.Text;
var toast = new ToastStatusMessage(new StatusMessage() { Message = $"You entered: {input}" });
toast.Show();
_stringParameter.ClearValue();
})
{
Name = "Submit",
Icon = new IconInfo("\uE724"), // Send
Result = CommandResult.KeepOpen(),
};
}
public override IParameterRun[] Parameters => new IParameterRun[]
{
new LabelRun("Enter a value:"),
_stringParameter,
};
public override IListItem Command => new ListItem(_command);
}
public sealed partial class ButtonParameterTest : ParametersPage
{
private readonly CommandParameterRun _fileParameter;
private readonly InvokableCommand _command;
public ButtonParameterTest()
{
Name = "Open";
Icon = new("\uE8E5");
_fileParameter = new FilePickerParameterRun();
_command = new AnonymousCommand(HandleInvoke)
{
Name = "Submit",
Icon = new IconInfo("\uE724"), // Send
Result = CommandResult.KeepOpen(),
};
}
public override IParameterRun[] Parameters => new IParameterRun[]
{
new LabelRun("Pick a file:"),
_fileParameter,
new LabelRun("and we'll open it"),
};
public override IListItem Command => new ListItem(_command);
private void HandleInvoke()
{
var input = (Windows.Storage.StorageFile?)_fileParameter.Value;
ToastStatusMessage? toast;
if (_fileParameter.Value is Windows.Storage.StorageFile file)
{
toast = new ToastStatusMessage(new StatusMessage() { Message = $"You entered: '{file.Path}'", State = MessageState.Success });
ShellHelpers.OpenInShell(file.Path);
}
else
{
toast = new ToastStatusMessage(new StatusMessage() { Message = $"no file selected", State = MessageState.Warning });
}
_fileParameter.ClearValue();
toast?.Show();
}
}
public sealed partial class MixedParamTestPage : ParametersPage
{
private readonly CommandParameterRun _fileParameter;
private readonly StringParameterRun _stringParameter;
private readonly InvokableCommand _command;
private readonly ListItem _item;
private bool _stringFirst;
public MixedParamTestPage(bool stringFirst = false)
{
_stringFirst = stringFirst;
Name = "Open";
Icon = new("\uED58");
_fileParameter = new FilePickerParameterRun();
_stringParameter = new StringParameterRun()
{
PlaceholderText = "Add a string",
};
_command = new AnonymousCommand(HandleInvoke)
{
Name = "Submit",
Icon = new IconInfo("\uE724"), // Send
Result = CommandResult.KeepOpen(),
};
_item = new(_command);
_fileParameter.PropChanged += (s, e) => ChangeCommandSubtitle();
_stringParameter.PropChanged += (s, e) => ChangeCommandSubtitle();
}
public override IParameterRun[] Parameters
{
get
{
return _stringFirst
? (new IParameterRun[]
{
new LabelRun("Add a string:"),
_stringParameter,
new LabelRun("and pick a file:"),
_fileParameter,
})
: (new IParameterRun[]
{
new LabelRun("Pick a file:"),
_fileParameter,
new LabelRun("and add a string:"),
_stringParameter,
});
}
}
public override IListItem Command => _item;
private void ChangeCommandSubtitle()
{
// set the subtitle to show the current values
var filePart = _fileParameter.Value is Windows.Storage.StorageFile file ? $"file='{file.Name}'" : "no file";
var stringPart = !string.IsNullOrWhiteSpace(_stringParameter.Text) ? $"string='{_stringParameter.Text}'" : "no string";
_item.Subtitle = $"{filePart}, {stringPart}";
}
private void HandleInvoke()
{
var input = (Windows.Storage.StorageFile?)_fileParameter.Value;
var toast = _fileParameter.Value is Windows.Storage.StorageFile file
? new ToastStatusMessage(new StatusMessage()
{
Message = $"You entered: '{file.Path}', '{_stringParameter.Text}'",
State = MessageState.Success,
})
: new ToastStatusMessage(new StatusMessage() { Message = $"no file selected", State = MessageState.Warning });
_fileParameter.ClearValue();
toast?.Show();
}
}
public sealed partial class CreateNoteParametersPage : ParametersPage
{
private readonly SelectFolderPage _selectFolderPage = new();
private readonly StringParameterRun _titleParameter;
private readonly CommandParameterRun _folderParameter;
private readonly List<IParameterRun> _parameters;
private readonly CreateNoteCommand _command;
private readonly ListItem _item;
public override IParameterRun[] Parameters => _parameters.ToArray();
public override IListItem Command => _item;
public CreateNoteParametersPage()
{
_titleParameter = new StringParameterRun()
{
PlaceholderText = "Note title",
};
_folderParameter = new CommandParameterRun()
{
PlaceholderText = "Select folder",
Command = _selectFolderPage,
};
_command = new() { TitleParameter = _titleParameter, FolderParameter = _folderParameter };
_item = new(_command);
_parameters = new List<IParameterRun>
{
new LabelRun("Create a note"),
_titleParameter,
new LabelRun("in"),
_folderParameter,
};
_selectFolderPage.FolderSelected += (s, folder) =>
{
_folderParameter.Value = folder;
_folderParameter.Icon = folder.Icon;
_folderParameter.DisplayText = folder.Name;
};
}
}
internal sealed class Folder
{
public string? Name { get; set; }
public IconInfo? Icon { get; set; }
}
internal sealed partial class CreateNoteCommand : InvokableCommand
{
internal required IStringParameterRun TitleParameter { get; init; } // set by the parameters page
internal required CommandParameterRun FolderParameter { get; init; } // set by the parameters page
public override IconInfo Icon => new("NoteAdd");
public override ICommandResult Invoke()
{
var title = TitleParameter.Text;
if (string.IsNullOrWhiteSpace(title))
{
var t = new ToastStatusMessage(new StatusMessage() { Message = "Title is required", State = MessageState.Error });
t.Show();
return CommandResult.KeepOpen();
}
var folder = FolderParameter.Value;
if (folder is not Folder)
{
// This is okay, we'll create the note in the default folder
}
// Create the note in the specified folder
NoteService.CreateNoteInFolder(title, folder); // whatever your backend is
return CommandResult.Dismiss();
}
}
public sealed partial class SelectFolderPage : ListPage
{
internal event EventHandler<Folder>? FolderSelected;
public SelectFolderPage()
{
}
private sealed partial class SelectFolderCommand : InvokableCommand
{
internal event EventHandler<Folder>? FolderSelected;
private readonly Folder _folder;
public override IconInfo Icon => _folder?.Icon ?? new(string.Empty);
public string Title => _folder?.Name ?? string.Empty;
public SelectFolderCommand(Folder folder)
{
_folder = folder;
}
public override ICommandResult Invoke()
{
FolderSelected?.Invoke(this, _folder);
return CommandResult.KeepOpen();
}
}
public override IListItem[] GetItems()
{
var listItems = new List<ListItem>();
// Populate the list with folders
var folders = FolderService.GetFolders(); // whatever your backend is
foreach (var value in folders)
{
var command = new SelectFolderCommand(value);
command.FolderSelected += (s, v) => { this.FolderSelected?.Invoke(this, v); };
var listItem = new ListItem(command);
listItems.Add(listItem);
}
return listItems.ToArray();
}
}
internal sealed class NoteService
{
internal static void CreateNoteInFolder(string title, object? folder)
{
// Your backend logic to create a note
var toast = new ToastStatusMessage(new StatusMessage() { Message = $"Created note '{title}'" });
toast.Show();
}
}
internal sealed class FolderService
{
internal static IEnumerable<Folder> GetFolders()
{
// Your backend logic to get folders
return new List<Folder>
{
new() { Name = "Personal", Icon = new IconInfo("\uEc25") },
new() { Name = "Work", Icon = new IconInfo("\uE821") },
new() { Name = "Ideas", Icon = new IconInfo("\uEA80") },
};
}
}
#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type
#nullable disable

View File

@@ -0,0 +1,361 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
using Windows.Foundation.Collections;
namespace SamplePagesExtension.Pages;
#nullable enable
#pragma warning disable SA1402 // File may only contain a single type
internal sealed partial class SampleSuggestionsPage : DynamicListPage, IExtendedAttributesProvider
{
private PeopleSearchPage _peopleSearchPage = new();
private CommandsListPage _commandsListPage = new();
private DynamicListPage? _suggestionPage;
private List<MyTokenType> _pickedTokens = new();
private int _lastPrefixPosition = -1;
private ListItem _queryItem;
// private int _lastCaretPosition = 0;
private string _searchText = string.Empty;
public override string SearchText
{
get => _searchText;
set
{
var oldSearch = _searchText;
if (value != oldSearch)
{
_searchText = value;
UpdateSearch(oldSearch, new SearchUpdateArgs(value, null));
}
}
}
internal SampleSuggestionsPage()
{
_peopleSearchPage.SuggestionPicked += OnSuggestionPicked;
_commandsListPage.SuggestionPicked += OnSuggestionPicked;
Name = "Open";
Title = "Sample prefixed search";
PlaceholderText = "Type a query, and use '@' to add a person";
Icon = new("\uE779");
_queryItem = new ListItem(new NoOpCommand())
{
Title = string.Empty,
Icon = new IconInfo("\uE8F2"), // ChatBubbles
};
}
public override IListItem[] GetItems()
{
return _suggestionPage?.GetItems() ??
(string.IsNullOrEmpty(this.SearchText) ?
[] :
[_queryItem]);
}
public void UpdateSearch(string oldSearchText, ISearchUpdateArgs args)
{
// if (args.GetProperties() is IDictionary<string, object> props)
// {
// if (props.TryGetValue("CaretPosition", out var caretPosObj) && caretPosObj is int caretPos)
// {
// _lastCaretPosition = caretPos;
// }
// }
var newSearchText = args.NewSearchText;
UpdateListItem(newSearchText);
if (string.IsNullOrEmpty(newSearchText) != string.IsNullOrEmpty(oldSearchText))
{
RaiseItemsChanged();
}
if (newSearchText.Length < oldSearchText.Length)
{
HandleDeletion(oldSearchText, newSearchText);
return;
}
this.SearchText = newSearchText;
// We're not doing caret tracking in this sample.
// Just assume caret is at end of text.
var lastCaretPosition = newSearchText.Length;
if (_suggestionPage == null)
{
var lastChar = newSearchText.Length > 0 && lastCaretPosition > 0 ?
newSearchText[lastCaretPosition - 1] :
'\0';
if (lastChar == '@')
{
// User typed '@', switch to people suggestion page
_lastPrefixPosition = lastCaretPosition - 1;
UpdateSuggestionPage(_peopleSearchPage);
}
else if (lastChar == '/')
{
// User typed '/', switch to commands suggestion page
_lastPrefixPosition = lastCaretPosition - 1;
UpdateSuggestionPage(_commandsListPage);
}
}
else if (_suggestionPage != null)
{
// figure out what part of the text applies to the current suggestion page
var startOfSubSearch = _lastPrefixPosition + 1;
var subString = _searchText.Substring(startOfSubSearch, lastCaretPosition - startOfSubSearch);
_suggestionPage.SearchText = subString;
// When the suggestion page updates its items, it should raise ItemsChanged event, which we will bubble through
}
}
private void OnSuggestionPicked(object sender, MyTokenType suggestion)
{
_pickedTokens.Add(suggestion);
UpdateSuggestionPage(null); // Clear suggestion page
var displayText = suggestion.DisplayName;
var tokenText = $"\u200B{displayText}\u200B "; // Add ZWSP before and after token, and a trailing space
// remove the prefix character and any partial text after it
if (_lastPrefixPosition >= 0 && _lastPrefixPosition < _searchText.Length)
{
_searchText = _searchText.Remove(_lastPrefixPosition);
}
// this.SearchText = this.SearchText.Insert(_lastCaretPosition, tokenText);
this.SearchText = _searchText + tokenText;
OnPropertyChanged(nameof(SearchText));
}
private void UpdateSuggestionPage(DynamicListPage? page)
{
if (_suggestionPage != null)
{
_suggestionPage.ItemsChanged -= OnSuggestedItemsChanged;
}
_suggestionPage = page;
if (_suggestionPage != null)
{
_suggestionPage.SearchText = string.Empty; // reset search text
_suggestionPage.ItemsChanged += OnSuggestedItemsChanged;
}
RaiseItemsChanged();
}
private void OnSuggestedItemsChanged(object sender, IItemsChangedEventArgs e)
{
RaiseItemsChanged();
}
private void HandleDeletion(string oldSearch, string newSearch)
{
var lastCaretPosition = newSearch.Length;
if (_suggestionPage != null)
{
if (lastCaretPosition <= _lastPrefixPosition)
{
// User deleted back over the prefix character, so close the suggestion page
UpdateSuggestionPage(null);
_lastPrefixPosition = -1;
return;
}
// figure out what part of the text applies to the current suggestion page
var startOfSubSearch = _lastPrefixPosition + 1;
if (lastCaretPosition <= _lastPrefixPosition)
{
// User deleted back over the prefix character, so close the suggestion page
UpdateSuggestionPage(null);
_lastPrefixPosition = -1;
}
else
{
var subString = newSearch.Substring(startOfSubSearch, lastCaretPosition - startOfSubSearch);
_suggestionPage.SearchText = subString;
}
}
}
private void UpdateListItem(string newSearchText)
{
// Iterate over the search text.
// Find all the strings that are surrounded by ZWSP characters.
// Use those strings to find all the matching picked tokens.
var index = 0;
var tokenSpans = new List<(int Start, int End, MyTokenType? Token)>();
while (index < newSearchText.Length)
{
var startIndex = newSearchText.IndexOf('\u200B', index);
if (startIndex < 0)
{
break;
}
var endIndex = newSearchText.IndexOf('\u200B', startIndex + 1);
if (endIndex < 0)
{
break;
}
var tokenText = newSearchText.Substring(startIndex + 1, endIndex - startIndex - 1);
var token = _pickedTokens.Find(t => t.DisplayName == tokenText);
tokenSpans.Add((startIndex, endIndex, token));
index = endIndex + 1;
}
// for each span, construct a string like $"[{start}, {end}): {token.DisplayName} {token.Id}\n"
var displayText = string.Empty;
foreach (var (start, end, token) in tokenSpans)
{
if (token != null)
{
displayText += $"[{start}, {end}): {token.DisplayName} {token.Id}\n";
}
}
_queryItem.Title = newSearchText;
_queryItem.Subtitle = string.IsNullOrEmpty(displayText) ? "no tokens" : displayText;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
// from DynamicListPage, not used
}
public IDictionary<string, object> GetProperties()
{
return new ValueSet()
{
{ "TokenSearch", true },
};
}
}
internal interface ISearchUpdateArgs
{
string NewSearchText { get; }
}
internal sealed partial class SearchUpdateArgs : ISearchUpdateArgs, IExtendedAttributesProvider
{
public string NewSearchText { get; }
private IDictionary<string, object> _properties;
public SearchUpdateArgs(string newSearchText, IDictionary<string, object>? properties)
{
NewSearchText = newSearchText;
_properties = properties ?? new Dictionary<string, object>();
}
public IDictionary<string, object> GetProperties() => _properties;
}
internal sealed partial class PeopleSearchPage : DynamicListPage
{
internal event TypedEventHandler<object, MyTokenType>? SuggestionPicked;
public override void UpdateSearchText(string oldSearch, string newSearch)
{
// do nothing
}
public override IListItem[] GetItems()
{
var items = new List<IListItem>();
for (var i = 1; i <= 5; i++)
{
var name = $"Person {i}";
var suggestion = new MyTokenType
{
DisplayName = name,
Id = Guid.NewGuid().ToString(),
Value = name,
};
items.Add(new ListItem(new PickSuggestionCommand(suggestion, SuggestionPicked))
{
Title = name,
Subtitle = $"Email: person{i}@example.com",
});
}
return items.ToArray();
}
}
internal sealed partial class CommandsListPage : DynamicListPage
{
internal event TypedEventHandler<object, MyTokenType>? SuggestionPicked;
public override void UpdateSearchText(string oldSearch, string newSearch)
{
// do nothing
}
public override IListItem[] GetItems()
{
var items = new List<IListItem>();
items.Add(new ListItem(new PickSuggestionCommand(new() { DisplayName = "Chat", Id = "chat" }, SuggestionPicked))
{
Title = "/chat",
Subtitle = $"send a message",
});
items.Add(new ListItem(new PickSuggestionCommand(new() { DisplayName = "Status", Id = "status" }, SuggestionPicked))
{
Title = "/status",
Subtitle = $"set your status",
});
return items.ToArray();
}
}
internal sealed partial class MyTokenType
{
public required string DisplayName { get; set; }
public string Id { get; set; } = string.Empty;
public object? Value { get; set; }
}
internal sealed partial class PickSuggestionCommand : InvokableCommand
{
internal MyTokenType Suggestion { get; private set; }
private TypedEventHandler<object, MyTokenType>? _pickedHandler;
public PickSuggestionCommand(MyTokenType suggestion, TypedEventHandler<object, MyTokenType>? pickedHandler)
{
Suggestion = suggestion;
_pickedHandler = pickedHandler;
Name = $"Select";
}
public override CommandResult Invoke()
{
_pickedHandler?.Invoke(this, Suggestion);
return CommandResult.KeepOpen();
}
}
#pragma warning restore SA1402 // File may only contain a single type
#nullable disable

View File

@@ -66,7 +66,33 @@
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishTrimmed>true</PublishTrimmed>
<PublishSingleFile>true</PublishSingleFile>
<PublishAot>true</PublishAot>
<IsAotCompatible>true</IsAotCompatible>
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
<CsWinRTAotWarningLevel>2</CsWinRTAotWarningLevel>
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
<WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors>
<!-- When publishing trimmed, make sure to treat trimming warnings as build errors -->
<ILLinkTreatWarningsAsErrors>true</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<!-- In Debug builds, trimming is disabled by default, but all the trim &
AOT warnings are enabled. This gives debug builds a tighter inner loop,
while at least warning about future trim violations -->
<PublishTrimmed>false</PublishTrimmed>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'!='Debug'">
<!-- In Release builds, trimming is enabled by default.
feel free to disable this if needed -->
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
<!-- For Debug builds, use standard JIT compilation -->

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -54,6 +54,11 @@ public partial class SamplesListPage : ListPage
Title = "Slow loading list page",
Subtitle = "A demo of a list page that takes a while to load",
},
new ListItem(new SampleSuggestionsPage())
{
Title = "Sample Prefix Suggestions",
Subtitle = "A demo of using 'nested' pages to provide 'suggestions' as the user types",
},
// Content pages
new ListItem(new SampleContentPage())
@@ -101,6 +106,35 @@ public partial class SamplesListPage : ListPage
Subtitle = "A demo of the settings helpers",
},
// Parameter pages
new ListItem(new SimpleParameterTest())
{
Title = "Sample parameters page",
Subtitle = "A demo of a command that takes simple parameters",
},
new ListItem(new ButtonParameterTest())
{
Title = "Button parameters page",
Subtitle = "A demo of a command that takes simple parameters",
},
new ListItem(new MixedParamTestPage(stringFirst: true))
{
Title = "Mixed parameter types (string first)",
Subtitle = "A demo of a command that takes multiple types of parameters",
},
new ListItem(new MixedParamTestPage(stringFirst: false))
{
Title = "Mixed parameter types (file first)",
Subtitle = "A demo of a command that takes multiple types of parameters",
},
// List parameters aren't yet supported
// new ListItem(new CreateNoteParametersPage())
// {
// Title = "Create note sample",
// Subtitle = "A parameter page with both a string and list parameter",
// },
// Evil edge cases
// Anything weird that might break the palette - put that in here.
new ListItem(new EvilSamplesPage())

View File

@@ -0,0 +1,75 @@
// 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.CommandPalette.Extensions.Toolkit;
public partial class CommandParameterRun : ParameterValueRun, ICommandParameterRun
{
private string? _displayText;
public virtual string? DisplayText
{
get => _displayText;
set
{
_displayText = value;
OnPropertyChanged(nameof(DisplayText));
}
}
private ICommand? _command;
public virtual ICommand? Command
{
get => _command;
set
{
_command = value;
OnPropertyChanged(nameof(Command));
}
}
private IIconInfo? _icon;
public virtual IIconInfo? Icon
{
get => _icon;
set
{
_icon = value;
OnPropertyChanged(nameof(Icon));
}
}
public override bool NeedsValue => Value == null;
public virtual ICommand? GetSelectValueCommand(ulong hostHwnd)
{
if (Command is IRequiresHostHwnd requiresHwnd)
{
requiresHwnd.SetHostHwnd((nint)hostHwnd);
}
return Command;
}
// Toolkit helper: a value for the parameter
private object? _value;
public override object? Value
{
get => _value;
set
{
_value = value;
OnPropertyChanged(nameof(Value));
OnPropertyChanged(nameof(NeedsValue));
}
}
public override void ClearValue()
{
Value = null;
}
}

View File

@@ -0,0 +1,89 @@
// 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 Windows.Storage;
using Windows.Storage.Pickers;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class FilePickerParameterRun : CommandParameterRun
{
public static readonly IconInfo AddIcon = new("\uE710"); // Add
public StorageFile? File { get; private set; }
public override object? Value => File;
public override string? DisplayText
{
get => File != null ?
File.Name :
Properties.Resources.FilePickerParameterRun_PlaceholderText;
}
public Action<FileOpenPicker>? SetupFilePicker { get; set; }
public FilePickerParameterRun()
{
var command = new FilePickerCommand();
command.FileSelected += (s, file) =>
{
File = file;
OnPropertyChanged(nameof(NeedsValue));
OnPropertyChanged(nameof(DisplayText));
};
command.RequestCustomizePicker += ConfigureFilePicker;
PlaceholderText = Properties.Resources.FilePickerParameterRun_PlaceholderText;
Icon = AddIcon;
Command = command;
}
public override void ClearValue()
{
File = null;
}
private sealed partial class FilePickerCommand : InvokableCommand, IRequiresHostHwnd
{
public override IconInfo Icon => FilePickerParameterRun.AddIcon;
public override string Name => Properties.Resources.FilePickerParameterRun_PlaceholderText;
public event EventHandler<StorageFile?>? FileSelected;
public event EventHandler<FileOpenPicker>? RequestCustomizePicker;
private nint _hostHwnd;
public void SetHostHwnd(nint hostHwnd)
{
_hostHwnd = hostHwnd;
}
public override ICommandResult Invoke()
{
PickFileAsync();
return CommandResult.KeepOpen();
}
private async void PickFileAsync()
{
var picker = new FileOpenPicker() { };
RequestCustomizePicker?.Invoke(this, picker);
// You need to initialize the picker with a window handle in WinUI 3 desktop apps
// See https://learn.microsoft.com/en-us/windows/apps/design/controls/file-open-picker
WinRT.Interop.InitializeWithWindow.Initialize(picker, (nint)_hostHwnd);
var file = await picker.PickSingleFileAsync();
FileSelected?.Invoke(this, file);
}
}
protected virtual void ConfigureFilePicker(object? sender, FileOpenPicker picker)
{
picker.FileTypeFilter.Add("*");
}
}

View File

@@ -0,0 +1,10 @@
// 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.CommandPalette.Extensions.Toolkit;
public interface IRequiresHostHwnd
{
void SetHostHwnd(nint hostHwnd);
}

View File

@@ -0,0 +1,29 @@
// 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.CommandPalette.Extensions.Toolkit;
public partial class LabelRun : BaseObservable, ILabelRun
{
private string? _text = string.Empty;
public virtual string? Text
{
get => _text;
set
{
_text = value;
OnPropertyChanged(nameof(Text));
}
}
public LabelRun(string text)
{
_text = text;
}
public LabelRun()
{
}
}

View File

@@ -0,0 +1,56 @@
// 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.CommandPalette.Extensions.Toolkit;
#nullable enable
public abstract partial class ParameterValueRun : BaseObservable, IParameterValueRun
{
private string _placeholderText = string.Empty;
public virtual string PlaceholderText
{
get => _placeholderText;
set
{
_placeholderText = value;
OnPropertyChanged(nameof(PlaceholderText));
}
}
private bool _needsValue = true;
// _required | _needsValue | out
// F | F | T
// F | T | T
// T | F | F
// T | T | T
public virtual bool NeedsValue
{
get => !_required || _needsValue;
set
{
_needsValue = value;
OnPropertyChanged(nameof(NeedsValue));
}
}
// Toolkit helper
private bool _required = true;
public virtual bool Required
{
get => _required;
set
{
_required = value;
OnPropertyChanged(nameof(NeedsValue));
}
}
public abstract void ClearValue();
public abstract object? Value { get; set; }
}
#nullable disable

View File

@@ -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.CommandPalette.Extensions.Toolkit;
public abstract partial class ParametersPage : Page, IParametersPage
{
public abstract IListItem Command { get; }
public abstract IParameterRun[] Parameters { get; }
}

View File

@@ -0,0 +1,30 @@
// 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 Windows.Foundation;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class SelectParameterCommand<T> : InvokableCommand
{
public event TypedEventHandler<object, T>? ValueSelected;
private T _value;
public T Value
{
get => _value; protected set { _value = value; }
}
public SelectParameterCommand(T value)
{
_value = value;
}
public override ICommandResult Invoke()
{
ValueSelected?.Invoke(this, _value);
return CommandResult.KeepOpen();
}
}

View File

@@ -0,0 +1,61 @@
// 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 Windows.Foundation;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class StaticParameterList<T> : ListPage
{
public event TypedEventHandler<object, T>? ValueSelected;
private readonly IEnumerable<T> _values;
private readonly List<IListItem> _items = new();
private bool _isInitialized;
private Func<T, ListItem, ListItem> _customizeListItemsCallback;
// ctor takes an IEnumerable<T> values, and a function to customize the ListItem's depending on the value
public StaticParameterList(IEnumerable<T> values, Func<T, ListItem> customizeListItem)
{
_values = values;
_customizeListItemsCallback = (value, listItem) =>
{
customizeListItem(value);
return listItem;
};
}
public StaticParameterList(IEnumerable<T> values, Func<T, ListItem, ListItem> customizeListItem)
{
_values = values;
_customizeListItemsCallback = customizeListItem;
}
public override IListItem[] GetItems()
{
if (!_isInitialized)
{
Initialize(_values, _customizeListItemsCallback);
_isInitialized = true;
}
return _items.ToArray();
}
private void Initialize(IEnumerable<T> values, Func<T, ListItem, ListItem> customizeListItem)
{
foreach (var value in values)
{
var command = new SelectParameterCommand<T>(value);
command.ValueSelected += (s, v) => ValueSelected?.Invoke(this, v);
var listItem = new ListItem(command);
var item = customizeListItem(value, listItem);
_items.Add(item);
}
}
}
#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type
#nullable disable

View File

@@ -0,0 +1,39 @@
// 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.CommandPalette.Extensions.Toolkit;
public partial class StringParameterRun : ParameterValueRun, IStringParameterRun
{
private string _text = string.Empty;
public virtual string Text
{
get => _text;
set
{
_text = value;
OnPropertyChanged(nameof(Text));
OnPropertyChanged(nameof(NeedsValue));
}
}
public override bool NeedsValue => string.IsNullOrEmpty(Text);
public StringParameterRun()
{
}
public StringParameterRun(string placeholderText)
{
PlaceholderText = placeholderText;
}
public override void ClearValue()
{
Text = string.Empty;
}
public override object? Value { get => Text; set => Text = (value is string s) ? s : string.Empty; }
}

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
@@ -114,6 +114,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Select a file.
/// </summary>
internal static string FilePickerParameterRun_PlaceholderText {
get {
return ResourceManager.GetString("FilePickerParameterRun_PlaceholderText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open.
/// </summary>

View File

@@ -157,4 +157,7 @@
<value>Copy failed ({0}). Please try again.</value>
<comment>{0} is the error message</comment>
</data>
<data name="FilePickerParameterRun_PlaceholderText" xml:space="preserve">
<value>Select a file</value>
</data>
</root>

View File

@@ -393,4 +393,47 @@ namespace Microsoft.CommandPalette.Extensions
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
[uuid("a2590cc9-510c-4af7-b562-a6b56fe37f55")]
interface IParameterRun requires INotifyPropChanged
{
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ILabelRun requires IParameterRun
{
String Text{ get; };
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IParameterValueRun requires IParameterRun
{
String PlaceholderText{ get; };
Boolean NeedsValue{ get; }; // TODO! name is weird
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IStringParameterRun requires IParameterValueRun
{
String Text{ get; set; };
// TODO! do we need a way to validate string inputs?
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface ICommandParameterRun requires IParameterValueRun
{
String DisplayText{ get; };
ICommand GetSelectValueCommand(UInt64 hostHwnd);
IIconInfo Icon{ get; }; // ? maybe
};
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IParametersPage requires IPage
{
IParameterRun[] Parameters{ get; };
IListItem Command{ get; };
};
}