diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c960a5..74a6f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Markdown rendering for snip descriptions.** A snip's description is now + rendered as Markdown (headings, bold/italic, inline and block code, links, + ordered/unordered lists) in the copy flyout, instead of being hidden. The + Markdown is parsed in Core (`MarkdownParser`, backed by Markdig) into a + UI-free model that the WinUI head maps onto native text; the editor labels + the field as Markdown. A snip with a description but no parameters now opens + the flyout too, so its description is always visible before copying. - **Git-derived version numbers (Nerdbank.GitVersioning).** Every build now stamps a real version (`0.1.0-alpha.+`) into all assemblies from a single `version.json`, so the About page shows a meaningful diff --git a/Directory.Packages.props b/Directory.Packages.props index 1f149da..d0e5d0f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/Snipdeck.App/Controls/MarkdownPresenter.cs b/src/Snipdeck.App/Controls/MarkdownPresenter.cs new file mode 100644 index 0000000..ee49632 --- /dev/null +++ b/src/Snipdeck.App/Controls/MarkdownPresenter.cs @@ -0,0 +1,199 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; + +using Snipdeck.Core.Services.Markdown; + +namespace Snipdeck.App.Controls +{ + /// + /// Renders a markdown string into WinUI text elements. The parsing lives in + /// Core (); this control only maps the resulting + /// UI-free model onto WinUI inlines and blocks. It builds itself as a vertical + /// stack, so no control template is needed. + /// + public sealed partial class MarkdownPresenter : StackPanel + { + private static readonly FontFamily _codeFont = new("Cascadia Mono, Consolas, Courier New"); + + public static readonly DependencyProperty MarkdownProperty = + DependencyProperty.Register( + nameof(Markdown), + typeof(string), + typeof(MarkdownPresenter), + new PropertyMetadata(null, OnMarkdownChanged)); + + public MarkdownPresenter() + { + Spacing = 8; + } + + public string? Markdown + { + get => (string?)GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((MarkdownPresenter)d).Rebuild(); + } + + private void Rebuild() + { + Children.Clear(); + foreach (var block in MarkdownParser.Parse(Markdown)) + { + var element = BuildBlock(block); + if (element is not null) + { + Children.Add(element); + } + } + } + + private static FrameworkElement? BuildBlock(MarkdownBlock block) + { + switch (block) + { + case HeadingBlock heading: + { + var text = NewTextBlock(heading.Level <= 2 ? "SubtitleTextBlockStyle" : "BodyStrongTextBlockStyle"); + AddInlines(text.Inlines, heading.Inlines); + return text; + } + case ParagraphBlock paragraph: + { + var text = NewTextBlock(styleKey: null); + AddInlines(text.Inlines, paragraph.Inlines); + return text; + } + case CodeBlock code: + return BuildCodeBlock(code.Text); + case ListBlock list: + return BuildList(list); + default: + return null; + } + } + + private static TextBlock NewTextBlock(string? styleKey) + { + var text = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + if (styleKey is not null + && Application.Current.Resources.TryGetValue(styleKey, out var style) + && style is Style typed) + { + text.Style = typed; + } + return text; + } + + private static Border BuildCodeBlock(string code) + { + var text = new TextBlock + { + Text = code, + FontFamily = _codeFont, + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + var border = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 6, 8, 6), + Child = text, + }; + if (Application.Current.Resources.TryGetValue("SubtleFillColorSecondaryBrush", out var brush) + && brush is Brush typed) + { + border.Background = typed; + } + return border; + } + + private static StackPanel BuildList(ListBlock list) + { + var container = new StackPanel { Spacing = 4 }; + for (var index = 0; index < list.Items.Count; index++) + { + var marker = list.Ordered ? $"{index + 1}." : "•"; + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var markerText = new TextBlock { Text = marker, Margin = new Thickness(0, 0, 8, 0) }; + Grid.SetColumn(markerText, 0); + + var itemText = new TextBlock { TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true }; + AddInlines(itemText.Inlines, list.Items[index].Inlines); + Grid.SetColumn(itemText, 1); + + row.Children.Add(markerText); + row.Children.Add(itemText); + container.Children.Add(row); + } + return container; + } + + private static void AddInlines(InlineCollection target, IReadOnlyList inlines) + { + foreach (var inline in inlines) + { + switch (inline) + { + case TextRun run: + target.Add(BuildRun(run.Text, run.Bold, run.Italic, run.Code)); + break; + case LinkRun link: + target.Add(BuildLink(link)); + break; + case LineBreakRun: + target.Add(new LineBreak()); + break; + default: + break; + } + } + } + + private static Run BuildRun(string text, bool bold, bool italic, bool code) + { + var run = new Run { Text = text }; + if (bold) + { + run.FontWeight = Microsoft.UI.Text.FontWeights.Bold; + } + if (italic) + { + run.FontStyle = Windows.UI.Text.FontStyle.Italic; + } + if (code) + { + run.FontFamily = _codeFont; + } + return run; + } + + private static Inline BuildLink(LinkRun link) + { + var inner = BuildRun(link.Text, link.Bold, link.Italic, code: false); + // Only wrap in a Hyperlink when the URL is a usable absolute URI; + // otherwise fall back to plain text so a malformed link can't break + // rendering. + if (Uri.TryCreate(link.Url, UriKind.Absolute, out var uri)) + { + var hyperlink = new Hyperlink { NavigateUri = uri }; + hyperlink.Inlines.Add(inner); + return hyperlink; + } + return inner; + } + } +} diff --git a/src/Snipdeck.App/Views/ParameterFillDialog.xaml b/src/Snipdeck.App/Views/ParameterFillDialog.xaml index 2228a95..f83ce6e 100644 --- a/src/Snipdeck.App/Views/ParameterFillDialog.xaml +++ b/src/Snipdeck.App/Views/ParameterFillDialog.xaml @@ -3,6 +3,7 @@ x:Class="Snipdeck.App.Views.ParameterFillDialog" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Snipdeck.App.Controls" xmlns:converters="using:Snipdeck.App.Converters" xmlns:vm="using:Snipdeck.Core.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" @@ -18,6 +19,10 @@ + + diff --git a/src/Snipdeck.App/Views/SnipEditorDialog.xaml b/src/Snipdeck.App/Views/SnipEditorDialog.xaml index 1080141..35d647d 100644 --- a/src/Snipdeck.App/Views/SnipEditorDialog.xaml +++ b/src/Snipdeck.App/Views/SnipEditorDialog.xaml @@ -24,7 +24,8 @@ AcceptsReturn="True" TextWrapping="Wrap" /> - + /// A small, UI-free representation of rendered markdown. The Core parser + /// produces this model from markdown source; a UI head maps it onto its own + /// native text primitives (WinUI inlines, etc.). Keeping the model here lets + /// the parsing be unit-tested without any UI dependency. + /// + public abstract record MarkdownBlock; + + /// A run of body text made up of styled inline spans. + public sealed record ParagraphBlock(IReadOnlyList Inlines) : MarkdownBlock; + + /// A heading ( 1-6) made up of inline spans. + public sealed record HeadingBlock(int Level, IReadOnlyList Inlines) : MarkdownBlock; + + /// A fenced or indented code block, rendered verbatim in a monospace box. + public sealed record CodeBlock(string Text) : MarkdownBlock; + + /// An ordered or unordered list. Items are flattened to inline content. + public sealed record ListBlock(bool Ordered, IReadOnlyList Items) : MarkdownBlock; + + /// A single list item's inline content. + public sealed record ListItem(IReadOnlyList Inlines); + + /// An inline span within a block. + public abstract record MarkdownInline; + + /// A span of text carrying emphasis flags. marks inline code. + public sealed record TextRun(string Text, bool Bold = false, bool Italic = false, bool Code = false) : MarkdownInline; + + /// A hyperlink span. + public sealed record LinkRun(string Text, string Url, bool Bold = false, bool Italic = false) : MarkdownInline; + + /// A hard line break within a paragraph. + public sealed record LineBreakRun : MarkdownInline; +} diff --git a/src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs b/src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs new file mode 100644 index 0000000..9f8f382 --- /dev/null +++ b/src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs @@ -0,0 +1,190 @@ +using System.Text; + +using Markdig; + +using MdInlines = Markdig.Syntax.Inlines; +using MdSyntax = Markdig.Syntax; + +namespace Snipdeck.Core.Services.Markdown +{ + /// + /// Parses markdown source into the UI-free model + /// using Markdig. A plain pipeline (no advanced extensions) keeps the output + /// predictable: paragraphs, headings, emphasis, inline/block code, links and + /// simple lists — the subset relevant to short snip descriptions. + /// + public static class MarkdownParser + { + private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder().Build(); + + public static IReadOnlyList Parse(string? markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + { + return []; + } + + var document = Markdig.Markdown.Parse(markdown, _pipeline); + var blocks = new List(); + foreach (var block in document) + { + AppendBlock(block, blocks); + } + return blocks; + } + + private static void AppendBlock(MdSyntax.Block block, List output) + { + switch (block) + { + case MdSyntax.HeadingBlock heading: + output.Add(new HeadingBlock(Math.Clamp(heading.Level, 1, 6), ParseInlines(heading.Inline))); + break; + case MdSyntax.ParagraphBlock paragraph: + output.Add(new ParagraphBlock(ParseInlines(paragraph.Inline))); + break; + // FencedCodeBlock derives from CodeBlock, so it must be matched first. + case MdSyntax.FencedCodeBlock fenced: + output.Add(new CodeBlock(fenced.Lines.ToString().TrimEnd('\r', '\n'))); + break; + case MdSyntax.CodeBlock code: + output.Add(new CodeBlock(code.Lines.ToString().TrimEnd('\r', '\n'))); + break; + case MdSyntax.ListBlock list: + { + var items = new List(); + foreach (var child in list) + { + if (child is MdSyntax.ListItemBlock listItem) + { + items.Add(new ListItem(ParseListItemInlines(listItem))); + } + } + output.Add(new ListBlock(list.IsOrdered, items)); + break; + } + // QuoteBlock and any other container: flatten its children. Both + // derive from ContainerBlock, so this arm must come last. + case MdSyntax.ContainerBlock container: + foreach (var child in container) + { + AppendBlock(child, output); + } + break; + default: + // Thematic breaks, raw HTML, etc. have no place in a snip + // description render — drop them silently. + break; + } + } + + private static List ParseListItemInlines(MdSyntax.ListItemBlock listItem) + { + var inlines = new List(); + foreach (var child in listItem) + { + if (child is MdSyntax.ParagraphBlock paragraph) + { + if (inlines.Count > 0) + { + inlines.Add(new LineBreakRun()); + } + inlines.AddRange(ParseInlines(paragraph.Inline)); + } + } + return inlines; + } + + private static List ParseInlines(MdInlines.ContainerInline? container) + { + var result = new List(); + if (container is not null) + { + WalkInlines(container, bold: false, italic: false, result); + } + return result; + } + + private static void WalkInlines(MdInlines.ContainerInline container, bool bold, bool italic, List output) + { + foreach (var inline in container) + { + switch (inline) + { + case MdInlines.LiteralInline literal: + { + var text = literal.Content.ToString(); + if (text.Length > 0) + { + output.Add(new TextRun(text, bold, italic)); + } + break; + } + case MdInlines.CodeInline code: + output.Add(new TextRun(code.Content, bold, italic, Code: true)); + break; + case MdInlines.EmphasisInline emphasis: + { + // One delimiter (*x* / _x_) is italic; two (**x**) is bold. + var nestedBold = emphasis.DelimiterCount >= 2 || bold; + var nestedItalic = emphasis.DelimiterCount == 1 || italic; + WalkInlines(emphasis, nestedBold, nestedItalic, output); + break; + } + case MdInlines.LinkInline { IsImage: false } link: + output.Add(new LinkRun(CollectText(link), link.Url ?? string.Empty, bold, italic)); + break; + case MdInlines.LinkInline image: + { + // No image rendering — fall back to the alt text. + var alt = CollectText(image); + if (alt.Length > 0) + { + output.Add(new TextRun(alt, bold, italic)); + } + break; + } + case MdInlines.LineBreakInline lineBreak: + // A hard break is an explicit newline; a soft break (a plain + // newline in the source) just keeps words separated. + output.Add(lineBreak.IsHard ? new LineBreakRun() : new TextRun(" ", bold, italic)); + break; + case MdInlines.ContainerInline nested: + WalkInlines(nested, bold, italic, output); + break; + default: + // Autolinks, raw inline HTML, etc. — ignored for this subset. + break; + } + } + } + + private static string CollectText(MdInlines.ContainerInline container) + { + var builder = new StringBuilder(); + CollectText(container, builder); + return builder.ToString(); + } + + private static void CollectText(MdInlines.ContainerInline container, StringBuilder builder) + { + foreach (var inline in container) + { + switch (inline) + { + case MdInlines.LiteralInline literal: + _ = builder.Append(literal.Content.ToString()); + break; + case MdInlines.CodeInline code: + _ = builder.Append(code.Content); + break; + case MdInlines.ContainerInline nested: + CollectText(nested, builder); + break; + default: + break; + } + } + } + } +} diff --git a/src/Snipdeck.Core/Snipdeck.Core.csproj b/src/Snipdeck.Core/Snipdeck.Core.csproj index 9df329e..1d93b35 100644 --- a/src/Snipdeck.Core/Snipdeck.Core.csproj +++ b/src/Snipdeck.Core/Snipdeck.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs index d30ff12..c522a8d 100644 --- a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs @@ -10,12 +10,17 @@ namespace Snipdeck.Core.ViewModels public sealed partial class ParameterFillViewModel : ObservableObject { private readonly Dictionary _values = new(StringComparer.Ordinal); + private readonly bool _hasParameters; public ParameterFillViewModel(Snip snip) { ArgumentNullException.ThrowIfNull(snip); Snip = snip; + // Captured before building Inputs: a parameter with a default fires its + // change callback (→ UpdateResolution) during construction, before the + // Inputs collection is assigned, so UpdateResolution can't read Inputs. + _hasParameters = snip.Parameters.Count > 0; Inputs = new ObservableCollection( snip.Parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged))); @@ -29,6 +34,10 @@ public ParameterFillViewModel(Snip snip) public Snip Snip { get; } + public string? Description => Snip.Description; + + public bool HasDescription => !string.IsNullOrWhiteSpace(Snip.Description); + public ObservableCollection Inputs { get; } [ObservableProperty] @@ -49,7 +58,12 @@ private void UpdateResolution() { var result = SubstitutionEngine.Substitute(Snip.CommandTemplate, _values); Preview = result.Text; - IsCopyEnabled = result.IsFullyResolved; + // With no parameters to fill there's nothing to gate on: the template + // copies verbatim (unresolved tokens are returned as-is), matching the + // direct, no-dialog copy path. This is the case for a snip opened in + // the flyout solely to show its description. Only gate copy on full + // resolution when there are actually inputs to fill. + IsCopyEnabled = !_hasParameters || result.IsFullyResolved; } } } diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs index 20674bb..0e986b6 100644 --- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -100,7 +100,11 @@ private async Task CopySnipAsync(SnipCardViewModel? cardVm) var snip = cardVm.Snip; string commandToCopy; - if (snip.Parameters.Count == 0) + // Skip the flyout only when there's nothing to show: no parameters to + // fill and no description to read. A described-but-parameterless snip + // still opens the flyout so its (rendered) description is visible + // before the copy. + if (snip.Parameters.Count == 0 && string.IsNullOrWhiteSpace(snip.Description)) { commandToCopy = snip.CommandTemplate; } diff --git a/tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs b/tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs new file mode 100644 index 0000000..0e51fa5 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs @@ -0,0 +1,98 @@ +using Snipdeck.Core.Services.Markdown; + +namespace Snipdeck.Core.Tests.Services +{ + public class MarkdownParserTests + { + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Blank_input_yields_no_blocks(string? input) + { + Assert.Empty(MarkdownParser.Parse(input)); + } + + [Fact] + public void Plain_paragraph_becomes_a_single_text_run() + { + var blocks = MarkdownParser.Parse("Just some text."); + + var paragraph = Assert.IsType(Assert.Single(blocks)); + var run = Assert.IsType(Assert.Single(paragraph.Inlines)); + Assert.Equal("Just some text.", run.Text); + Assert.False(run.Bold); + Assert.False(run.Italic); + Assert.False(run.Code); + } + + [Fact] + public void Bold_italic_and_inline_code_set_the_run_flags() + { + var blocks = MarkdownParser.Parse("**b** *i* `c`"); + var paragraph = Assert.IsType(Assert.Single(blocks)); + + var bold = Assert.IsType(paragraph.Inlines[0]); + Assert.Equal("b", bold.Text); + Assert.True(bold.Bold); + + var italic = paragraph.Inlines.OfType().Single(r => r.Text == "i"); + Assert.True(italic.Italic); + + var code = paragraph.Inlines.OfType().Single(r => r.Text == "c"); + Assert.True(code.Code); + } + + [Fact] + public void Heading_captures_level_and_text() + { + var heading = Assert.IsType(Assert.Single(MarkdownParser.Parse("### Usage"))); + Assert.Equal(3, heading.Level); + Assert.Equal("Usage", Assert.IsType(Assert.Single(heading.Inlines)).Text); + } + + [Fact] + public void Fenced_code_block_is_captured_verbatim() + { + var blocks = MarkdownParser.Parse("```\npl-app deploy --env prod\n```"); + + var code = Assert.IsType(Assert.Single(blocks)); + Assert.Equal("pl-app deploy --env prod", code.Text); + } + + [Fact] + public void Unordered_list_yields_items() + { + var list = Assert.IsType(Assert.Single(MarkdownParser.Parse("- first\n- second"))); + Assert.False(list.Ordered); + Assert.Equal(2, list.Items.Count); + Assert.Equal("first", Assert.IsType(Assert.Single(list.Items[0].Inlines)).Text); + Assert.Equal("second", Assert.IsType(Assert.Single(list.Items[1].Inlines)).Text); + } + + [Fact] + public void Ordered_list_is_flagged_ordered() + { + var list = Assert.IsType(Assert.Single(MarkdownParser.Parse("1. one\n2. two"))); + Assert.True(list.Ordered); + Assert.Equal(2, list.Items.Count); + } + + [Fact] + public void Link_captures_text_and_url() + { + var paragraph = Assert.IsType(Assert.Single(MarkdownParser.Parse("See [the docs](https://example.com/x)."))); + var link = paragraph.Inlines.OfType().Single(); + Assert.Equal("the docs", link.Text); + Assert.Equal("https://example.com/x", link.Url); + } + + [Fact] + public void Hard_line_break_becomes_a_line_break_run() + { + // Two trailing spaces before the newline is a markdown hard break. + var paragraph = Assert.IsType(Assert.Single(MarkdownParser.Parse("line one \nline two"))); + Assert.Contains(paragraph.Inlines, i => i is LineBreakRun); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs index 6a98463..9601d32 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs @@ -17,6 +17,22 @@ public void Snip_with_no_parameters_is_immediately_copy_enabled_with_template_as Assert.Empty(vm.Inputs); } + [Fact] + public void Parameterless_snip_with_token_like_template_text_stays_copy_enabled() + { + // No declared parameters, but the template contains a bare {token} that + // the engine treats as an unresolved placeholder. There's nothing to + // fill, so it must copy verbatim (as the direct copy path does) rather + // than being gated off as "unresolved". + var snip = new Snip { CommandTemplate = "git commit -m {message}" }; + + var vm = new ParameterFillViewModel(snip); + + Assert.Empty(vm.Inputs); + Assert.True(vm.IsCopyEnabled); + Assert.Equal("git commit -m {message}", vm.Preview); + } + [Fact] public void Defaults_pre_fill_inputs_and_drive_copy_enabled_state() { diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs index 545a451..f7c9b1d 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -65,6 +65,35 @@ public async Task CopySnip_with_no_parameters_writes_clipboard_directly_and_bump Assert.Equal(clock.UtcNow, store.Document.Snips[0].LastUsedAt); } + [Fact] + public async Task CopySnip_opens_the_flyout_for_a_described_snip_even_without_parameters() + { + Cli cli = null!; + var (vm, _, clip, ix, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + // No parameters, but a description worth reading before copy. + d.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Status", + CommandTemplate = "pl-app status", + Description = "Shows **cluster** status.", + }); + }); + + ix.NextParameterFillResult = new ParameterFillResult("pl-app status"); + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.CopySnipCommand.ExecuteAsync(card); + + // The flyout was shown (so the description renders) rather than copying directly. + Assert.NotNull(ix.LastFilledSnip); + Assert.Equal("pl-app status", clip.LastText); + } + [Fact] public async Task CopySnip_with_parameters_uses_resolved_command_from_interactions() {