Repository: microsoft/terminal (language: cpp)

Issue:
Ability to save selected text as a `sendInput` action
I write some long command. I need to use this frequently (or infrequently), but I don't want to have to remember all the args. I want to be able to pull it up straight from the command palette. So I want to write the command once, select it, and {perform some action}[^1] that lets me save it as a `sendInput` action.

The action should
* immediately save it.
* provide a UI[^2] for immediately editing[^3] the name, contents of this action, because `Send "git log --graph --abbrev-commit --decorate..." to the Terminal` is not useful to anyone.

Just doing the immediate save would be a good enough start on this, to the point I'd consider that an easy starter. The UI - that's **H**ard, for reasons listed below. 


> **Note**
> ## Walkthrough

As an initial version of this, it's more than good enough to just save the selected text as an action without the UI. We can iterate on the UI in follow-ups. 

* Probably start with something like [`dev/migrie/fhl/save-command`](https://github.com/microsoft/terminal/compare/6f5b9fb...1cde67ac466e19394eea1eb3a41405513d160a6f)
* We probably want to skip all the `AppCommandlineArgs` stuff for now. That's spicier.
* in `TerminalPage::_HandleSaveTask`, we will want to try and save the current selection as a `sendInput` action, if there's no `commandline` specified. 
* AppActionHandlers.cpp has plenty of examples of getting the active control. Should be easy enough to get the selected text from it. 
* We'll probably want to generate the name of this action (without a commandline) as "Save Task..."
* Over and above: It would be valuable to `Toast` the user with a message "Saved selected text as a new action".  `TerminalPage::IdentifyWindow` has an example of a `Toast`

[^1]: Obviously, easiest way is via cmdpal or keybinding. Also consider a right-click context menu entry, #3337

[^2]: This has it's own challenges. It can't be a `ContentDialog` (https://github.com/microsoft/microsoft-ui-xaml/issues/3804). It could be nested in a `Flyout`, maybe. Hopefully the dialog thing is limited just to ContentDialogs and not all Flyouts. It probably shouldn't be a `TeachingTip` (#12798). We _could_ roll our own ContentDialog for this of course (#11308).

[^3]: Might be tricky - we'd have to hand on to an instance of the actual `Command` itself, so we can edit it... in place? and hopefully writing to the settings would then just refresh all the other name->command maps instantly.


Discussion / hints:
/assign
Can I work on this? : )

Fix patch:
diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp
index 724f7dafb70..ac01af8cb3b 100644
--- a/src/cascadia/TerminalApp/AppActionHandlers.cpp
+++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp
@@ -1264,6 +1264,113 @@ namespace winrt::TerminalApp::implementation
         }
     }
 
+    void TerminalPage::_HandleSaveSnippet(const IInspectable& /*sender*/,
+                                          const ActionEventArgs& args)
+    {
+        if constexpr (!Feature_SaveSnippet::IsEnabled())
+        {
+            return;
+        }
+
+        if (args)
+        {
+            if (const auto& realArgs = args.ActionArgs().try_as<SaveSnippetArgs>())
+            {
+                auto commandLine = realArgs.Commandline();
+                if (commandLine.empty())
+                {
+                    if (const auto termControl{ _GetActiveControl() })
+                    {
+                        if (termControl.HasSelection())
+                        {
+                            const auto selections{ termControl.SelectedText(true) };
+                            const auto selection = std::accumulate(selections.begin(), selections.end(), std::wstring());
+                            commandLine = selection;
+                        }
+                    }
+                }
+
+                if (commandLine.empty())
+                {
+                    ActionSaveFailed(L"CommandLine is Required");
+                    return;
+                }
+
+                try
+                {
+                    KeyChord keyChord = nullptr;
+                    if (!realArgs.KeyChord().empty())
+                    {
+                        keyChord = KeyChordSerialization::FromString(winrt::to_hstring(realArgs.KeyChord()));
+                    }
+                    _settings.GlobalSettings().ActionMap().AddSendInputAction(realArgs.Name(), commandLine, keyChord);
+                    _settings.WriteSettingsToDisk();
+                    ActionSaved(commandLine, realArgs.Name(), realArgs.KeyChord());
+                }
+                catch (const winrt::hresult_error& ex)
+                {
+                    auto code = ex.code();
+                    auto message = ex.message();
+                    ActionSaveFailed(message);
+                    args.Handled(true);
+                    return;
+                }
+
+                args.Handled(true);
+            }
+        }
+    }
+
+    void TerminalPage::ActionSaved(winrt::hstring input, winrt::hstring name, winrt::hstring keyChord)
+    {
+        // If we haven't ever loaded the TeachingTip, then do so now and
+        // create the toast for it.
+        if (_actionSavedToast == nullptr)
+        {
+            if (auto tip{ FindName(L"ActionSavedToast").try_as<MUX::Controls::TeachingTip>() })
+            {
+                _actionSavedToast = std::make_shared<Toast>(tip);
+                // Make sure to use the weak ref when setting up this
+                // callback.
+                tip.Closed({ get_weak(), &TerminalPage::_FocusActiveControl });
+            }
+        }
+        _UpdateTeachingTipTheme(ActionSavedToast().try_as<winrt::Windows::UI::Xaml::FrameworkElement>());
+
+        SavedActionName(name);
+        SavedActionKeyChord(keyChord);
+        SavedActionCommandLine(input);
+
+        if (_actionSavedToast != nullptr)
+        {
+            _actionSavedToast->Open();
+        }
+    }
+
+    void TerminalPage::ActionSaveFailed(winrt::hstring message)
+    {
+        // If we haven't ever loaded the TeachingTip, then do so now and
+        // create the toast for it.
+        if (_actionSaveFailedToast == nullptr)
+        {
+            if (auto tip{ FindName(L"ActionSaveFailedToast").try_as<MUX::Controls::TeachingTip>() })
+            {
+                _actionSaveFailedToast = std::make_shared<Toast>(tip);
+                // Make sure to use the weak ref when setting up this
+                // callback.
+                tip.Closed({ get_weak(), &TerminalPage::_FocusActiveControl });
+            }
+        }
+        _UpdateTeachingTipTheme(ActionSaveFailedToast().try_as<winrt::Windows::UI::Xaml::FrameworkElement>());
+
+        ActionSaveFailedMessage().Text(message);
+
+        if (_actionSaveFailedToast != nullptr)
+        {
+            _actionSaveFailedToast->Open();
+        }
+    }
+
     void TerminalPage::_HandleSelectCommand(const IInspectable& /*sender*/,
                                             const ActionEventArgs& args)
     {
diff --git a/src/cascadia/TerminalApp/AppCommandlineArgs.cpp b/src/cascadia/TerminalApp/AppCommandlineArgs.cpp
index b7a6b581a05..c1154f56728 100644
--- a/src/cascadia/TerminalApp/AppCommandlineArgs.cpp
+++ b/src/cascadia/TerminalApp/AppCommandlineArgs.cpp
@@ -209,6 +209,7 @@ void AppCommandlineArgs::_buildParser()
     _buildMovePaneParser();
     _buildSwapPaneParser();
     _buildFocusPaneParser();
+    _buildSaveSnippetParser();
 }
 
 // Method Description:
@@ -537,6 +538,72 @@ void AppCommandlineArgs::_buildFocusPaneParser()
     setupSubcommand(_focusPaneShort);
 }
 
+void AppCommandlineArgs::_buildSaveSnippetParser()
+{
+    _saveCommand = _app.add_subcommand("x-save-snippet", RS_A(L"SaveSnippetDesc"));
+
+    auto setupSubcommand = [this](auto* subcommand) {
+        subcommand->add_option("--name,-n", _saveInputName, RS_A(L"SaveSnippetArgDesc"));
+        subcommand->add_option("--keychord,-k", _keyChordOption, RS_A(L"KeyChordArgDesc"));
+        subcommand->add_option("command,", _commandline, RS_A(L"CmdCommandArgDesc"));
+        subcommand->positionals_at_end(true);
+
+        // When ParseCommand is called, if this subcommand was provided, this
+        // callback function will be triggered on the same thread. We can be sure
+        // that `this` will still be safe - this function just lets us know this
+        // command was parsed.
+        subcommand->callback([&, this]() {
+            // Build the action from the values we've parsed on the commandline.
+            ActionAndArgs saveSnippet{};
+            saveSnippet.Action(ShortcutAction::SaveSnippet);
+            // First, parse out the commandline in the same way that
+            // _getNewTerminalArgs does it
+            SaveSnippetArgs args{};
+
+            if (!_commandline.empty())
+            {
+                std::ostringstream cmdlineBuffer;
+
+                for (const auto& arg : _commandline)
+                {
+                    if (cmdlineBuffer.tellp() != 0)
+                    {
+                        // If there's already something in here, prepend a space
+                        cmdlineBuffer << ' ';
+                    }
+
+                    if (arg.find(" ") != std::string::npos)
+                    {
+                        cmdlineBuffer << '"' << arg << '"';
+                    }
+                    else
+                    {
+                        cmdlineBuffer << arg;
+                    }
+                }
+
+                args.Commandline(winrt::to_hstring(cmdlineBuffer.str()));
+            }
+
+            if (!_keyChordOption.empty())
+            {
+                args.KeyChord(winrt::to_hstring(_keyChordOption));
+            }
+
+            if (!_saveInputName.empty())
+            {
+                winrt::hstring hString = winrt::to_hstring(_saveInputName);
+                args.Name(hString);
+            }
+
+            saveSnippet.Args(args);
+            _startupActions.push_back(saveSnippet);
+        });
+    };
+
+    setupSubcommand(_saveCommand);
+}
+
 // Method Description:
 // - Add the `NewTerminalArgs` parameters to the given subcommand. This enables
 //   that subcommand to support all the properties in a NewTerminalArgs.
@@ -710,7 +777,8 @@ bool AppCommandlineArgs::_noCommandsProvided()
              *_focusPaneCommand ||
              *_focusPaneShort ||
              *_newPaneShort.subcommand ||
-             *_newPaneCommand.subcommand);
+             *_newPaneCommand.subcommand ||
+             *_saveCommand);
 }
 
 // Method Description:
diff --git a/src/cascadia/TerminalApp/AppCommandlineArgs.h b/src/cascadia/TerminalApp/AppCommandlineArgs.h
index 7eb5a34f93d..7eb2516bb38 100644
--- a/src/cascadia/TerminalApp/AppCommandlineArgs.h
+++ b/src/cascadia/TerminalApp/AppCommandlineArgs.h
@@ -93,6 +93,7 @@ class TerminalApp::AppCommandlineArgs final
     CLI::App* _swapPaneCommand;
     CLI::App* _focusPaneCommand;
     CLI::App* _focusPaneShort;
+    CLI::App* _saveCommand;
 
     // Are you adding a new sub-command? Make sure to update _noCommandsProvided!
 
@@ -123,6 +124,8 @@ class TerminalApp::AppCommandlineArgs final
     bool _focusPrevTab{ false };
 
     int _focusPaneTarget{ -1 };
+    std::string _saveInputName;
+    std::string _keyChordOption;
     // Are you adding more args here? Make sure to reset them in _resetStateToDefault
 
     const Commandline* _currentCommandline{ nullptr };
@@ -141,6 +144,7 @@ class TerminalApp::AppCommandlineArgs final
     winrt::Microsoft::Terminal::Settings::Model::NewTerminalArgs _getNewTerminalArgs(NewTerminalSubcommand& subcommand);
     void _addNewTerminalArgs(NewTerminalSubcommand& subcommand);
     void _buildParser();
+    void _buildSaveSnippetParser();
     void _buildNewTabParser();
     void _buildSplitPaneParser();
     void _buildFocusTabParser();
diff --git a/src/cascadia/TerminalApp/Resources/en-US/Resources.resw b/src/cascadia/TerminalApp/Resources/en-US/Resources.resw
index 15b79c40ffc..efe3e1278e2 100644
--- a/src/cascadia/TerminalApp/Resources/en-US/Resources.resw
+++ b/src/cascadia/TerminalApp/Resources/en-US/Resources.resw
@@ -288,6 +288,15 @@
   <data name="CmdCommandArgDesc" xml:space="preserve">
     <value>An optional command, with arguments, to be spawned in the new tab or pane</value>
   </data>
+  <data name="SaveSnippetDesc" xml:space="preserve">
+    <value>Save command line as input action</value>
+  </data>
+  <data name="SaveSnippetArgDesc" xml:space="preserve">
+    <value>An optional argument</value>
+  </data>
+  <data name="KeyChordArgDesc" xml:space="preserve">
+    <value>An optional argument</value>
+  </data>
   <data name="CmdFocusTabDesc" xml:space="preserve">
     <value>Move focus to another tab</value>
   </data>
@@ -898,4 +907,10 @@
   <data name="RestartConnectionToolTip" xml:space="preserve">
     <value>Restart the active pane connection</value>
   </data>
+  <data name="ActionSavedToast.Title" xml:space="preserve">
+    <value>Action saved</value>
+  </data>
+  <data name="ActionSaveFailedToast.Title" xml:space="preserve">
+    <value>Action save failed</value>
+  </data>
 </root>
diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj
index d0629da8036..238fecf9afc 100644
--- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj
+++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj
@@ -357,7 +357,9 @@
   </ItemGroup>
   <!-- ========================= Misc Files ======================== -->
   <ItemGroup>
-    <PRIResource Include="Resources\en-US\Resources.resw" />
+    <PRIResource Include="Resources\en-US\Resources.resw">
+      <SubType>Designer</SubType>
+    </PRIResource>
     <PRIResource Include="Resources\en-US\ContextMenu.resw" />
     <OCResourceDirectory Include="Resources" />
   </ItemGroup>
@@ -466,10 +468,8 @@
   </ItemDefinitionGroup>
   <!-- ========================= Globals ======================== -->
   <Import Project="$(OpenConsoleDir)src\cppwinrt.build.post.props" />
-
   <!-- This -must- go after cppwinrt.build.post.props because that includes many VS-provided props including appcontainer.common.props, which stomps on what cppwinrt.targets did. -->
   <Import Project="$(OpenConsoleDir)src\common.nugetversions.targets" />
-
   <!--
     By default, the PRI file will contain resource paths beginning with the
     project name. Since we enabled XBF embedding, this *also* includes App.xbf.
@@ -490,4 +490,4 @@
     </ItemGroup>
   </Target>
   <Import Project="$(SolutionDir)build\rules\CollectWildcardResources.targets" />
-</Project>
+</Project>
\ No newline at end of file
diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h
index 0a6f5d70957..07dbf173242 100644
--- a/src/cascadia/TerminalApp/TerminalPage.h
+++ b/src/cascadia/TerminalApp/TerminalPage.h
@@ -146,6 +146,8 @@ namespace winrt::TerminalApp::implementation
         winrt::hstring KeyboardServiceDisabledText();
 
         winrt::fire_and_forget IdentifyWindow();
+        void ActionSaved(winrt::hstring input, winrt::hstring name, winrt::hstring keyChord);
+        void ActionSaveFailed(winrt::hstring message);
         winrt::fire_and_forget RenameFailed();
         winrt::fire_and_forget ShowTerminalWorkingDirectory();
 
@@ -199,6 +201,10 @@ namespace winrt::TerminalApp::implementation
         WINRT_OBSERVABLE_PROPERTY(winrt::Windows::UI::Xaml::Media::Brush, TitlebarBrush, PropertyChanged.raise, nullptr);
         WINRT_OBSERVABLE_PROPERTY(winrt::Windows::UI::Xaml::Media::Brush, FrameBrush, PropertyChanged.raise, nullptr);
 
+        WINRT_OBSERVABLE_PROPERTY(winrt::hstring, SavedActionName, PropertyChanged.raise, L"");
+        WINRT_OBSERVABLE_PROPERTY(winrt::hstring, SavedActionKeyChord, PropertyChanged.raise, L"");
+        WINRT_OBSERVABLE_PROPERTY(winrt::hstring, SavedActionCommandLine, PropertyChanged.raise, L"");
+
     private:
         friend struct TerminalPageT<TerminalPage>; // for Xaml to bind events
         std::optional<HWND> _hostingHwnd;
@@ -258,6 +264,8 @@ namespace winrt::TerminalApp::implementation
         bool _isEmbeddingInboundListener{ false };
 
         std::shared_ptr<Toast> _windowIdToast{ nullptr };
+        std::shared_ptr<Toast> _actionSavedToast{ nullptr };
+        std::shared_ptr<Toast> _actionSaveFailedToast{ nullptr };
         std::shared_ptr<Toast> _windowRenameFailedToast{ nullptr };
         std::shared_ptr<Toast> _windowCwdToast{ nullptr };
 
diff --git a/src/cascadia/TerminalApp/TerminalPage.idl b/src/cascadia/TerminalApp/TerminalPage.idl
index 667350056b8..9e272351406 100644
--- a/src/cascadia/TerminalApp/TerminalPage.idl
+++ b/src/cascadia/TerminalApp/TerminalPage.idl
@@ -72,6 +72,10 @@ namespace TerminalApp
         void IdentifyWindow();
         void RenameFailed();
 
+        String SavedActionName { get; };
+        String SavedActionKeyChord { get; };
+        String SavedActionCommandLine { get; };
+
         // We cannot use the default XAML APIs because we want to make sure
         // that there's only one application-global dialog visible at a time,
         // and because of GH#5224.
diff --git a/src/cascadia/TerminalApp/TerminalPage.xaml b/src/cascadia/TerminalApp/TerminalPage.xaml
index c8aa837bf82..ce6fc576071 100644
--- a/src/cascadia/TerminalApp/TerminalPage.xaml
+++ b/src/cascadia/TerminalApp/TerminalPage.xaml
@@ -8,6 +8,7 @@
       xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
       xmlns:local="using:TerminalApp"
       xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+      xmlns:mtu="using:Microsoft.Terminal.UI"
       xmlns:mux="using:Microsoft.UI.Xaml.Controls"
       Background="Transparent"
       mc:Ignorable="d">
@@ -204,5 +205,43 @@
                          Title="{x:Bind WindowProperties.VirtualWorkingDirectory, Mode=OneWay}"
                          x:Load="False"
                          IsLightDismissEnabled="True" />
+
+        <mux:TeachingTip x:Name="ActionSavedToast"
+                         x:Uid="ActionSavedToast"
+                         Title="Action Saved"
+                         HorizontalAlignment="Stretch"
+                         x:Load="False"
+                         IsLightDismissEnabled="True">
+            <mux:TeachingTip.Content>
+                <StackPanel HorizontalAlignment="Stretch"
+                            Orientation="Vertical">
+                    <TextBlock x:Name="ActionSavedNameText"
+                               Visibility="{x:Bind mtu:Converters.StringNotEmptyToVisibility(SavedActionName), Mode=OneWay}">
+                        <Run Text="Name: " />
+                        <Run Text="{x:Bind SavedActionName, Mode=OneWay}" />
+                    </TextBlock>
+                    <TextBlock x:Name="ActionSavedKeyChordText"
+                               Visibility="{x:Bind mtu:Converters.StringNotEmptyToVisibility(SavedActionKeyChord), Mode=OneWay}">
+                        <Run Text="Key Chord: " />
+                        <Run Text="{x:Bind SavedActionKeyChord, Mode=OneWay}" />
+                    </TextBlock>
+                    <TextBlock x:Name="ActionSavedCommandLineText"
+                               Visibility="{x:Bind mtu:Converters.StringNotEmptyToVisibility(SavedActionCommandLine), Mode=OneWay}">
+                        <Run Text="Input: " />
+                        <Run Text="{x:Bind SavedActionCommandLine, Mode=OneWay}" />
+                    </TextBlock>
+                </StackPanel>
+            </mux:TeachingTip.Content>
+        </mux:TeachingTip>
+        <mux:TeachingTip x:Name="ActionSaveFailedToast"
+                         x:Uid="ActionSaveFailedToast"
+                         Title="Action Save Failed"
+                         x:Load="False"
+                         IsLightDismissEnabled="True">
+            <mux:TeachingTip.Content>
+                <TextBlock x:Name="ActionSaveFailedMessage"
+                           Text="" />
+            </mux:TeachingTip.Content>
+        </mux:TeachingTip>
     </Grid>
 </Page>
diff --git a/src/cascadia/TerminalSettingsModel/ActionAndArgs.cpp b/src/cascadia/TerminalSettingsModel/ActionAndArgs.cpp
index 7bba13bc4cc..9b2ef821f58 100644
--- a/src/cascadia/TerminalSettingsModel/ActionAndArgs.cpp
+++ b/src/cascadia/TerminalSettingsModel/ActionAndArgs.cpp
@@ -52,6 +52,7 @@ static constexpr std::string_view SwitchToTabKey{ "switchToTab" };
 static constexpr std::string_view TabSearchKey{ "tabSearch" };
 static constexpr std::string_view ToggleAlwaysOnTopKey{ "toggleAlwaysOnTop" };
 static constexpr std::string_view ToggleCommandPaletteKey{ "commandPalette" };
+static constexpr std::string_view SaveSnippetKey{ "experimental.saveSnippet" };
 static constexpr std::string_view SuggestionsKey{ "showSuggestions" };
 static constexpr std::string_view ToggleFocusModeKey{ "toggleFocusMode" };
 static constexpr std::string_view SetFocusModeKey{ "setFocusMode" };
@@ -389,6 +390,7 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
                 { ShortcutAction::TabSearch, RS_(L"TabSearchCommandKey") },
                 { ShortcutAction::ToggleAlwaysOnTop, RS_(L"ToggleAlwaysOnTopCommandKey") },
                 { ShortcutAction::ToggleCommandPalette, MustGenerate },
+                { ShortcutAction::SaveSnippet, MustGenerate },
                 { ShortcutAction::Suggestions, MustGenerate },
                 { ShortcutAction::ToggleFocusMode, RS_(L"ToggleFocusModeCommandKey") },
                 { ShortcutAction::SetFocusMode, MustGenerate },
diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.cpp b/src/cascadia/TerminalSettingsModel/ActionArgs.cpp
index 24f4e445953..d018b6019e4 100644
--- a/src/cascadia/TerminalSettingsModel/ActionArgs.cpp
+++ b/src/cascadia/TerminalSettingsModel/ActionArgs.cpp
@@ -33,6 +33,7 @@
 #include "ScrollToMarkArgs.g.cpp"
 #include "AddMarkArgs.g.cpp"
 #include "FindMatchArgs.g.cpp"
+#include "SaveSnippetArgs.g.cpp"
 #include "ToggleCommandPaletteArgs.g.cpp"
 #include "SuggestionsArgs.g.cpp"
 #include "NewWindowArgs.g.cpp"
@@ -947,6 +948,29 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
         }
     }
 
+    winrt::hstring SaveSnippetArgs::GenerateName() const
+    {
+        if (Feature_SaveSnippet::IsEnabled())
+        {
+            std::wstringstream ss;
+
+            ss << RS_(L"SaveSnippetNamePrefix").c_str() << L" commandline: " << Commandline().c_str();
+
+            if (!Name().empty())
+            {
+                ss << L", name: " << Name().c_str();
+            }
+
+            if (!KeyChord().empty())
+            {
+                ss << L", keyChord " << KeyChord().c_str();
+            }
+
+            return winrt::hstring{ ss.str() };
+        }
+        return L"";
+    }
+
     static winrt::hstring _FormatColorString(const Control::SelectionColor& selectionColor)
     {
         if (!selectionColor)
diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.h b/src/cascadia/TerminalSettingsModel/ActionArgs.h
index ae3d7d50029..84c23dd2d7d 100644
--- a/src/cascadia/TerminalSettingsModel/ActionArgs.h
+++ b/src/cascadia/TerminalSettingsModel/ActionArgs.h
@@ -34,6 +34,7 @@
 #include "ScrollToMarkArgs.g.h"
 #include "AddMarkArgs.g.h"
 #include "MoveTabArgs.g.h"
+#include "SaveSnippetArgs.g.h"
 #include "ToggleCommandPaletteArgs.g.h"
 #include "SuggestionsArgs.g.h"
 #include "FindMatchArgs.g.h"
@@ -215,6 +216,12 @@ protected:                                                                  \
 #define TOGGLE_COMMAND_PALETTE_ARGS(X) \
     X(CommandPaletteLaunchMode, LaunchMode, "launchMode", false, CommandPaletteLaunchMode::Action)
 
+////////////////////////////////////////////////////////////////////////////////
+#define SAVE_TASK_ARGS(X)                                                           \
+    X(winrt::hstring, Name, "name", false, L"")                                     \
+    X(winrt::hstring, Commandline, "commandline", args->Commandline().empty(), L"") \
+    X(winrt::hstring, KeyChord, "keyChord", false, L"")
+
 ////////////////////////////////////////////////////////////////////////////////
 #define SUGGESTIONS_ARGS(X)                                                 \
     X(SuggestionsSource, Source, "source", false, SuggestionsSource::Tasks) \
@@ -819,6 +826,8 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
 
     ACTION_ARGS_STRUCT(ToggleCommandPaletteArgs, TOGGLE_COMMAND_PALETTE_ARGS);
 
+    ACTION_ARGS_STRUCT(SaveSnippetArgs, SAVE_TASK_ARGS);
+
     ACTION_ARGS_STRUCT(SuggestionsArgs, SUGGESTIONS_ARGS);
 
     ACTION_ARGS_STRUCT(FindMatchArgs, FIND_MATCH_ARGS);
@@ -941,6 +950,7 @@ namespace winrt::Microsoft::Terminal::Settings::Model::factory_implementation
     BASIC_FACTORY(CloseTabArgs);
     BASIC_FACTORY(MoveTabArgs);
     BASIC_FACTORY(OpenSettingsArgs);
+    BASIC_FACTORY(SaveSnippetArgs);
     BASIC_FACTORY(FindMatchArgs);
     BASIC_FACTORY(NewWindowArgs);
     BASIC_FACTORY(FocusPaneArgs);
diff --git a/src/cascadia/TerminalSettingsModel/ActionArgs.idl b/src/cascadia/TerminalSettingsModel/ActionArgs.idl
index 5992f2d5217..02f0c4d0413 100644
--- a/src/cascadia/TerminalSettingsModel/ActionArgs.idl
+++ b/src/cascadia/TerminalSettingsModel/ActionArgs.idl
@@ -357,6 +357,15 @@ namespace Microsoft.Terminal.Settings.Model
         FindMatchDirection Direction { get; };
     };
 
+    [default_interface] runtimeclass SaveSnippetArgs : IActionArgs
+    {
+        SaveSnippetArgs();
+        SaveSnippetArgs(String Name, String Commandline, String KeyChord);
+        String Name;
+        String Commandline;
+        String KeyChord;
+    };
+
     [default_interface] runtimeclass NewWindowArgs : IActionArgs
     {
         NewWindowArgs(INewContentArgs contentArgs);
diff --git a/src/cascadia/TerminalSettingsModel/ActionMap.cpp b/src/cascadia/TerminalSettingsModel/ActionMap.cpp
index 4468014c5a8..86a88c36858 100644
--- a/src/cascadia/TerminalSettingsModel/ActionMap.cpp
+++ b/src/cascadia/TerminalSettingsModel/ActionMap.cpp
@@ -882,6 +882,22 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
         return results;
     }
 
+    void ActionMap::AddSendInputAction(winrt::hstring name, winrt::hstring input, const Control::KeyChord keys)
+    {
+        auto newAction = winrt::make<ActionAndArgs>();
+        newAction.Action(ShortcutAction::SendInput);
+        auto sendInputArgs = winrt::make<SendInputArgs>(input);
+        newAction.Args(sendInputArgs);
+        auto cmd{ make_self<Command>() };
+        if (!name.empty())
+        {
+            cmd->Name(name);
+        }
+        cmd->ActionAndArgs(newAction);
+        cmd->GenerateID();
+        AddAction(*cmd, keys);
+    }
+
     IVector<Model::Command> ActionMap::FilterToSendInput(
         winrt::hstring currentCommandline)
     {
diff --git a/src/cascadia/TerminalSettingsModel/ActionMap.h b/src/cascadia/TerminalSettingsModel/ActionMap.h
index 2b815214dcb..22428ee5eda 100644
--- a/src/cascadia/TerminalSettingsModel/ActionMap.h
+++ b/src/cascadia/TerminalSettingsModel/ActionMap.h
@@ -78,6 +78,7 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
         bool RebindKeys(const Control::KeyChord& oldKeys, const Control::KeyChord& newKeys);
         void DeleteKeyBinding(const Control::KeyChord& keys);
         void RegisterKeyBinding(Control::KeyChord keys, Model::ActionAndArgs action);
+        void AddSendInputAction(winrt::hstring name, winrt::hstring input, const Control::KeyChord keys);
 
         Windows::Foundation::Collections::IVector<Model::Command> ExpandedCommands();
         void ExpandCommands(const Windows::Foundation::Collections::IVectorView<Model::Profile>& profiles,
diff --git a/src/cascadia/TerminalSettingsModel/ActionMap.idl b/src/cascadia/TerminalSettingsModel/ActionMap.idl
index 594872f6b70..1849e0680a4 100644
--- a/src/cascadia/TerminalSettingsModel/ActionMap.idl
+++ b/src/cascadia/TerminalSettingsModel/ActionMap.idl
@@ -31,5 +31,6 @@ namespace Microsoft.Terminal.Settings.Model
         void DeleteKeyBinding(Microsoft.Terminal.Control.KeyChord keys);
 
         void RegisterKeyBinding(Microsoft.Terminal.Control.KeyChord keys, ActionAndArgs action);
+        void AddSendInputAction(String name, String input, Microsoft.Terminal.Control.KeyChord keys);
     }
 }
diff --git a/src/cascadia/TerminalSettingsModel/AllShortcutActions.h b/src/cascadia/TerminalSettingsModel/AllShortcutActions.h
index 28ba4281e69..86ff9d49c9b 100644
--- a/src/cascadia/TerminalSettingsModel/AllShortcutActions.h
+++ b/src/cascadia/TerminalSettingsModel/AllShortcutActions.h
@@ -75,6 +75,7 @@
     ON_ALL_ACTIONS(CloseTabsAfter)          \
     ON_ALL_ACTIONS(TabSearch)               \
     ON_ALL_ACTIONS(MoveTab)                 \
+    ON_ALL_ACTIONS(SaveSnippet)             \
     ON_ALL_ACTIONS(BreakIntoDebugger)       \
     ON_ALL_ACTIONS(TogglePaneReadOnly)      \
     ON_ALL_ACTIONS(EnablePaneReadOnly)      \
@@ -148,6 +149,7 @@
     ON_ALL_ACTIONS_WITH_ARGS(SplitPane)            \
     ON_ALL_ACTIONS_WITH_ARGS(SwitchToTab)          \
     ON_ALL_ACTIONS_WITH_ARGS(ToggleCommandPalette) \
+    ON_ALL_ACTIONS_WITH_ARGS(SaveSnippet)          \
     ON_ALL_ACTIONS_WITH_ARGS(FocusPane)            \
     ON_ALL_ACTIONS_WITH_ARGS(ExportBuffer)         \
     ON_ALL_ACTIONS_WITH_ARGS(ClearBuffer)          \
diff --git a/src/cascadia/TerminalSettingsModel/Resources/en-US/Resources.resw b/src/cascadia/TerminalSettingsModel/Resources/en-US/Resources.resw
index 6179099bdf5..b65b7eabcd4 100644
--- a/src/cascadia/TerminalSettingsModel/Resources/en-US/Resources.resw
+++ b/src/cascadia/TerminalSettingsModel/Resources/en-US/Resources.resw
@@ -1,17 +1,17 @@
 <?xml version="1.0" encoding="utf-8"?>
 <root>
-  <!-- 
-    Microsoft ResX Schema 
-    
+  <!--
+    Microsoft ResX Schema
+
     Version 2.0
-    
-    The primary goals of this format is to allow a simple XML format 
-    that is mostly human readable. The generation and parsing of the 
-    various data types are done through the TypeConverter classes 
+
+    The primary goals of this format is to allow a simple XML format
+    that is mostly human readable. The generation and parsing of the
+    various data types are done through the TypeConverter classes
     associated with the data types.
-    
+
     Example:
-    
+
     ... ado.net/XML headers & schema ...
     <resheader name="resmimetype">text/microsoft-resx</resheader>
     <resheader name="version">2.0</resheader>
@@ -26,36 +26,36 @@
         <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
         <comment>This is a comment</comment>
     </data>
-                
-    There are any number of "resheader" rows that contain simple 
+
+    There are any number of "resheader" rows that contain simple
     name/value pairs.
-    
-    Each data row contains a name, and value. The row also contains a 
-    type or mimetype. Type corresponds to a .NET class that support 
-    text/value conversion through the TypeConverter architecture. 
-    Classes that don't support this are serialized and stored with the 
+
+    Each data row contains a name, and value. The row also contains a
+    type or mimetype. Type corresponds to a .NET class that support
+    text/value conversion through the TypeConverter architecture.
+    Classes that don't support this are serialized and stored with the
     mimetype set.
-    
-    The mimetype is used for serialized objects, and tells the 
-    ResXResourceReader how to depersist the object. This is currently not 
+
+    The mimetype is used for serialized objects, and tells the
+    ResXResourceReader how to depersist the object. This is currently not
     extensible. For a given mimetype the value must be set accordingly:
-    
-    Note - application/x-microsoft.net.object.binary.base64 is the format 
-    that the ResXResourceWriter will generate, however the reader can 
+
+    Note - application/x-microsoft.net.object.binary.base64 is the format
+    that the ResXResourceWriter will generate, however the reader can
     read any of the formats listed below.
-    
+
     mimetype: application/x-microsoft.net.object.binary.base64
-    value   : The object must be serialized with 
+    value   : The object must be serialized with
             : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
             : and then encoded with base64 encoding.
-    
+
     mimetype: application/x-microsoft.net.object.soap.base64
-    value   : The object must be serialized with 
+    value   : The object must be serialized with
             : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
             : and then encoded with base64 encoding.
 
     mimetype: application/x-microsoft.net.object.bytearray.base64
-    value   : The object must be serialized into a byte array 
+    value   : The object must be serialized into a byte array
             : using a System.ComponentModel.TypeConverter
             : and then encoded with base64 encoding.
     -->
@@ -727,4 +727,7 @@
     <value>Open about dialog</value>
     <comment>This will open the "about" dialog, to display version info and other documentation</comment>
   </data>
-</root>
\ No newline at end of file
+  <data name="SaveSnippetNamePrefix" xml:space="preserve">
+    <value>Save Snippet</value>
+  </data>
+</root>
diff --git a/src/features.xml b/src/features.xml
index 47d86790f9d..b87e09bb7c4 100644
--- a/src/features.xml
+++ b/src/features.xml
@@ -155,6 +155,17 @@
         </alwaysEnabledBrandingTokens>
     </feature>
 
+    <feature>
+        <name>Feature_SaveSnippet</name>
+        <description>Save Snippet</description>
+        <id>9971</id>
+        <stage>AlwaysDisabled</stage>
+        <alwaysEnabledBrandingTokens>
+            <brandingToken>Dev</brandingToken>
+            <brandingToken>Canary</brandingToken>
+        </alwaysEnabledBrandingTokens>
+    </feature>
+
     <feature>
         <name>Feature_QuickFix</name>
         <description>Enables the Quick Fix menu</description>


Test patch: