From e10164e3e65d6b1b422f64bb59763ba5af99e344 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:40:12 -0700 Subject: [PATCH 01/36] Add interactive selection for ambiguous package matches --- doc/ReleaseNotes.md | 6 + .../#5345 - Interactive package selection.md | 112 +++++++ .../Commands/DownloadCommand.cpp | 2 +- .../Commands/InstallCommand.cpp | 2 +- .../Commands/ShowCommand.cpp | 4 +- src/AppInstallerCLICore/ExecutionContext.h | 2 +- src/AppInstallerCLICore/ExecutionReporter.cpp | 103 ++++++ src/AppInstallerCLICore/ExecutionReporter.h | 9 + src/AppInstallerCLICore/Resources.h | 7 + .../Workflows/PromptFlow.cpp | 43 ++- .../Workflows/PromptFlow.h | 2 + .../Workflows/ShowFlow.cpp | 2 +- src/AppInstallerCLICore/Workflows/ShowFlow.h | 4 +- .../Workflows/UpdateFlow.cpp | 4 +- .../Workflows/UpdateFlow.h | 11 +- .../Workflows/WorkflowBase.cpp | 98 +++++- .../Workflows/WorkflowBase.h | 5 +- .../Shared/Strings/en-us/winget.resw | 28 ++ src/AppInstallerCLITests/PromptFlow.cpp | 307 ++++++++++++++++++ src/AppInstallerCLITests/SourceFlow.cpp | 265 +++++++++++++++ 20 files changed, 973 insertions(+), 43 deletions(-) create mode 100644 doc/specs/#5345 - Interactive package selection.md diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 41c77e1db0..6db5aef38f 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -2,6 +2,12 @@ ## New Features +### Interactive package selection + +When multiple packages match a single-package `install`, `show`, or `download` command, WinGet offers a numbered choice in an interactive terminal. Enter a package number to continue or `0` to cancel. Package IDs and sources remain visible without truncation. + +Scripts and other noninteractive callers retain the existing ambiguity error. Use `--id --exact --source ` to select a package explicitly, or `--disable-interactivity` to prevent prompts. + ### Source priority Source priority is now available without enabling an experimental feature. Use `winget source add --priority ` or `winget source edit --name --priority ` to configure it. Higher values take precedence; sources with equal priority still require disambiguation when multiple matches remain. diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md new file mode 100644 index 0000000000..f42b03f215 --- /dev/null +++ b/doc/specs/#5345 - Interactive package selection.md @@ -0,0 +1,112 @@ +--- +author: AmelBawa-msft, GitHub Copilot +created on: 2026-09-28 +last updated: 2026-09-28 +issue id: 5345 +--- + +# Interactive package selection + +For [#5345](https://github.com/microsoft/winget-cli/issues/5345) + +## Abstract + +Let users resolve ambiguous package matches without restarting their command. Single-package `install`, `show`, and `download` offer numbered choices when interactive input and output are available. + +## Inspiration + +The same query can match multiple packages, including packages from different sources. Users should be able to choose deliberately without copying an ID into another invocation. + +## Solution Design + +Apply existing search matching and source-priority rules first. If multiple candidates remain, eligible CLI call sites opt into selection. Shared workflows remain noninteractive by default. + +Display candidates in their existing order with stable, one-based numbers. A valid number selects the existing package object without searching again. Preserve command options and continue normal version selection, applicability checks, and agreement handling. + +| Situation | Behavior | +| --- | --- | +| No match | Existing no-match error. | +| One match after existing policy | Continue without prompting. | +| Multiple matches for single-package `install`, `show`, or `download` | Prompt if eligible, including `show --versions`. | +| Truncated results | Retain ambiguity error and request refinement. | +| Invalid or empty input | Explain the valid range and reprompt; no default. | +| `0` | Cancel without acting on a package. | +| Ctrl+C | Cancel immediately, including while waiting for input. | +| EOF or input failure | Report the existing prompt input error. | +| `--disable-interactivity`, interactivity disabled in settings or context, or `--silent` | Retain ambiguity error without reading input. | +| Redirected input or output, or disabled informational output | Do not prompt. | +| `--no-vt` | Use the same text and numeric input without terminal escape sequences. | +| Multi-package operations, upgrade, uninstall, repair, pin, search, list, or completion | Preserve existing behavior. | +| COM API, PowerShell cmdlets, or configuration/DSC | No new prompts or API changes. | + +The prompt adds no settings, command-line flags, group policies, manifest fields, or schema versions. Existing interactivity settings apply. Package validation pipelines and manifest authoring tools are unchanged; manifest examples and schema snippets are not applicable. + +## UI/UX Design + +Use the existing search table layout with a leading selection number. Show the Source column only when candidates span sources. For example: + +```text +Multiple packages match. Choose one to install. + +# Name Id Version +------------------------------------------- +1 Contoso Editor Contoso.Editor 2.4.0 +2 Contoso Editor Contoso.Editor.Pro 2.4.0 + +Enter a number (1-2), or 0 to cancel: 1 +Selected: Contoso Editor [Contoso.Editor] +``` + +For candidates spanning sources: + +```text +# Name Id Version Source +----------------------------------------------- +1 Contoso Editor Contoso.Editor 2.4.0 winget +2 Contoso Editor Contoso.Editor 2.4.0 private +``` + +Each source row uses that source's package name, ID, and version. Additional sources within one candidate appear on continuation rows without another selection number; name and ID are omitted only when both match the preceding row. Use action-specific introductory text for viewing or downloading. Do not add another confirmation after selection. Existing consent prompts still apply. + +When prompting is unavailable, retain the candidate list and explain: + +```text +Specify a package with --id --exact --source . +``` + +Displayed versions are source metadata, not a promise of installer applicability. The original version option remains authoritative. + +## Capabilities + +### Accessibility + +Numeric, line-oriented input works without color, cursor navigation, or arrow keys. All identifying information is text and uses localized labels. Invalid input includes recovery instructions. + +### Security + +There is no default selection or inferred equivalence. Choosing a package does not accept agreements or bypass existing source, trust, or installer checks. + +### Reliability + +Selection uses the displayed candidate object rather than re-running a potentially different search. EOF fails explicitly; cancellation never starts installation or download. + +### Compatibility + +Only explicitly opted-in, interactive single-package commands change behavior. Scripts retain existing ambiguity errors and can use exact ID and source selectors. + +### Performance, Power, and Efficiency + +Rendering uses available package metadata, without downloading manifests for display. No terminal redraw loop is required. + +## Potential Issues + +Long candidate lists require scrolling. Narrow terminals truncate table cells using the existing formatter; users can widen the terminal or cancel and refine their query if candidates are indistinguishable. The selection message includes the full name and ID. Source-defined result truncation must not be presented as a complete selectable list. Matching names and IDs do not prove that packages from different sources are equivalent. + +## Future Considerations + +Cross-source equivalence heuristics, arrow-key navigation, and selection for installed-package operations are separate changes. + +## Resources + +- [Package matching background](%23292%20-%20winget%20should%20install%20an%20app%20if%20there%20is%20an%20exact%20match.md) +- [Settings reference](../Settings.md) diff --git a/src/AppInstallerCLICore/Commands/DownloadCommand.cpp b/src/AppInstallerCLICore/Commands/DownloadCommand.cpp index 21854d985c..8ae798c512 100644 --- a/src/AppInstallerCLICore/Commands/DownloadCommand.cpp +++ b/src/AppInstallerCLICore/Commands/DownloadCommand.cpp @@ -126,7 +126,7 @@ namespace AppInstaller::CLI Workflow::OpenSource() << Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Download) << + Workflow::EnsureOneMatchFromSearchResult(OperationType::Download, true) << Workflow::GetManifestFromPackage(false); } diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp index ab52f9076c..9ea50d0303 100644 --- a/src/AppInstallerCLICore/Commands/InstallCommand.cpp +++ b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -167,7 +167,7 @@ namespace AppInstaller::CLI { context << Checkpoint("PreInstallCheckpoint", {}) << // TODO: Capture context data - InstallOrUpgradeSinglePackage(OperationType::Install); + InstallOrUpgradeSinglePackage(OperationType::Install, true); } } } diff --git a/src/AppInstallerCLICore/Commands/ShowCommand.cpp b/src/AppInstallerCLICore/Commands/ShowCommand.cpp index 796711bf19..8522484d8a 100644 --- a/src/AppInstallerCLICore/Commands/ShowCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ShowCommand.cpp @@ -93,7 +93,7 @@ namespace AppInstaller::CLI Workflow::OpenSource() << Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Show) << + Workflow::EnsureOneMatchFromSearchResult(OperationType::Show, true) << Workflow::ReportPackageIdentity << Workflow::ShowAppVersions; } @@ -101,7 +101,7 @@ namespace AppInstaller::CLI else { context << - GetManifest( /* considerPins */ false) << + GetManifest(false, true) << Workflow::ReportManifestIdentity << Workflow::SelectInstaller << Workflow::ShowManifestInfo; diff --git a/src/AppInstallerCLICore/ExecutionContext.h b/src/AppInstallerCLICore/ExecutionContext.h index d4cfd96fe7..eae42fbdee 100644 --- a/src/AppInstallerCLICore/ExecutionContext.h +++ b/src/AppInstallerCLICore/ExecutionContext.h @@ -202,7 +202,7 @@ namespace AppInstaller::CLI::Execution private: DestructionToken m_disableSignalTerminationHandlerOnExit = false; - bool m_isTerminated = false; + std::atomic m_isTerminated = false; HRESULT m_terminationHR = S_OK; size_t m_CtrlSignalCount = 0; ContextFlag m_flags = ContextFlag::None; diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index 6bf720e6e9..74c3703eb5 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "ExecutionReporter.h" #include +#include namespace AppInstaller::CLI::Execution @@ -44,6 +45,9 @@ namespace AppInstaller::CLI::Execution { m_outStreamFileType = GetStdHandleType(STD_OUTPUT_HANDLE); m_inStreamFileType = GetStdHandleType(STD_INPUT_HANDLE); + DWORD mode = 0; + m_consoleStreams = GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) && + GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &mode); } Reporter::Reporter(std::ostream& outStream, std::istream& inStream) : @@ -79,6 +83,7 @@ namespace AppInstaller::CLI::Execution { m_outStreamFileType = other.m_outStreamFileType; m_inStreamFileType = other.m_inStreamFileType; + m_consoleStreams = other.m_consoleStreams; SetChannel(other.m_channel); @@ -176,6 +181,104 @@ namespace AppInstaller::CLI::Execution return m_inStreamFileType == FILE_TYPE_CHAR; } + bool Reporter::CanPrompt() + { + return m_consoleStreams && Info().IsEnabled(); + } + + std::optional Reporter::PromptForSelection(size_t count, std::function isCancelled) + { + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !CanPrompt() || !count); + + wil::unique_handle inputThread; + THROW_IF_WIN32_BOOL_FALSE(DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), + inputThread.put(), THREAD_TERMINATE, FALSE, 0)); + + auto out = Info(); + for (;;) + { + if (isCancelled && isCancelled()) + { + return std::nullopt; + } + out << Resource::String::PackageSelectionPrompt(count) << ' ' << std::flush; + + std::string response; + bool readSucceeded = false; + DWORD readError = ERROR_SUCCESS; + ProgressCallback progress; + wil::unique_event readCompleted{ wil::EventOptions::ManualReset }; + auto cancellation = progress.SetCancellationFunction([&]() + { + // Retry until the read ends to cover cancellation immediately before it starts. + while (!readCompleted.wait(10)) + { + if (!CancelSynchronousIo(inputThread.get())) + { + DWORD error = GetLastError(); + if (error != ERROR_NOT_FOUND) + { + LOG_WIN32(error); + } + } + } + }); + SetProgressCallback(&progress); + { + auto unregister = wil::scope_exit([&]() + { + readCompleted.SetEvent(); + SetProgressCallback(nullptr); + }); + if (!isCancelled || !isCancelled()) + { + if (m_inStreamFileType == FILE_TYPE_CHAR) + { + std::wstring consoleResponse; + do + { + wchar_t buffer[256]; + DWORD charactersRead = 0; + SetLastError(ERROR_SUCCESS); + // The CRT loses ERROR_OPERATION_ABORTED when Ctrl+C ends a console read. + bool succeeded = ReadConsoleW(GetStdHandle(STD_INPUT_HANDLE), buffer, ARRAYSIZE(buffer), &charactersRead, nullptr); + readError = GetLastError(); + readSucceeded = succeeded && charactersRead != 0; + if (!readSucceeded || readError == ERROR_OPERATION_ABORTED) + { + break; + } + consoleResponse.append(buffer, charactersRead); + } while (consoleResponse.back() != L'\n'); + response = Utility::ConvertToUTF8(consoleResponse); + readSucceeded = readSucceeded && response.find('\x1a') == std::string::npos; + } + else + { + SetLastError(ERROR_SUCCESS); + readSucceeded = static_cast(std::getline(m_in, response)); + readError = GetLastError(); + } + } + } + if (progress.IsCancelledBy(CancelReason::Any) || (isCancelled && isCancelled()) || readError == ERROR_OPERATION_ABORTED) + { + return std::nullopt; + } + THROW_HR_IF(APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR, !readSucceeded); + Utility::Trim(response); + + size_t selection = 0; + auto result = std::from_chars(response.data(), response.data() + response.size(), selection); + if (result.ec == std::errc{} && result.ptr == response.data() + response.size() && selection <= count) + { + return selection ? std::optional{ selection - 1 } : std::nullopt; + } + + out << Resource::String::PackageSelectionInvalid(count) << std::endl; + } + } + bool Reporter::PromptForBoolResponse(Resource::LocString message, Level level, bool resultIfDisabled) { auto out = GetOutputStream(level); diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index d4598b8d67..877a748cd0 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -113,6 +113,14 @@ namespace AppInstaller::CLI::Execution // Check if the input stream is interactive or not. bool InputStreamIsInteractive() const; + bool CanPrompt(); + + std::optional PromptForSelection(size_t count, std::function isCancelled = {}); + +#ifndef AICLI_DISABLE_TEST_HOOKS + void SetConsoleStreamsForTest(bool value) { m_consoleStreams = value; } +#endif + // Prompts the user, return true if they consented. bool PromptForBoolResponse(Resource::LocString message, Level level = Level::Info, bool resultIfDisabled = false); @@ -210,6 +218,7 @@ namespace AppInstaller::CLI::Execution std::atomic m_progressSink; DWORD m_outStreamFileType = FILE_TYPE_UNKNOWN; DWORD m_inStreamFileType = FILE_TYPE_UNKNOWN; + bool m_consoleStreams = false; // Enable all levels by default Level m_enabledLevels = Level::All; diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 68e194d5f1..d6c9f6a27b 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -532,6 +532,13 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PackageDependencies); WINGET_DEFINE_RESOURCE_STRINGID(PackageIsPinned); WINGET_DEFINE_RESOURCE_STRINGID(PackageRequiresDependencies); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionDownload); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInstall); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInvalid); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionPrompt); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefine); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionSelected); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionShow); WINGET_DEFINE_RESOURCE_STRINGID(PendingWorkError); WINGET_DEFINE_RESOURCE_STRINGID(PinAddBlockingArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(PinAddCommandLongDescription); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 5e298f5c92..068e0ddb65 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -11,36 +11,31 @@ using namespace AppInstaller::Utility::literals; namespace AppInstaller::CLI::Workflow { - namespace + bool IsInteractivityAllowed(Execution::Context& context) { - bool IsInteractivityAllowed(Execution::Context& context) + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) { - // Interactivity can be disabled for several reasons: - // * We are running in a non-interactive context (e.g., COM call) - // * It is disabled in the settings - // * It was disabled from the command line - - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); - return false; - } - - if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); - return false; - } + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); + return false; + } - if (Settings::User().Get()) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); - return false; - } + if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); + return false; + } - return true; + if (Settings::User().Get()) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); + return false; } + return true; + } + + namespace + { bool HandleSourceAgreementsForOneSource(Execution::Context& context, const Repository::Source& source) { auto details = source.GetDetails(); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 7219cb1fff..735cfba9d4 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -5,6 +5,8 @@ namespace AppInstaller::CLI::Workflow { + bool IsInteractivityAllowed(Execution::Context& context); + // Handles all opened source(s) agreements if needed. // Required Args: The source to be checked for agreements // Inputs: None diff --git a/src/AppInstallerCLICore/Workflows/ShowFlow.cpp b/src/AppInstallerCLICore/Workflows/ShowFlow.cpp index eb872b2110..fa6a230020 100644 --- a/src/AppInstallerCLICore/Workflows/ShowFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/ShowFlow.cpp @@ -226,7 +226,7 @@ namespace AppInstaller::CLI::Workflow OpenSource() << SearchSourceForSingle << HandleSearchResultFailures << - EnsureOneMatchFromSearchResult(OperationType::Show) << + EnsureOneMatchFromSearchResult(OperationType::Show, m_allowSelection) << GetManifestFromPackage(m_considerPins); } } diff --git a/src/AppInstallerCLICore/Workflows/ShowFlow.h b/src/AppInstallerCLICore/Workflows/ShowFlow.h index 618a1795cb..055c975417 100644 --- a/src/AppInstallerCLICore/Workflows/ShowFlow.h +++ b/src/AppInstallerCLICore/Workflows/ShowFlow.h @@ -44,12 +44,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: Manifest struct GetManifest : public WorkflowTask { - GetManifest(bool considerPins) : WorkflowTask("GetManifest"), m_considerPins(considerPins) {} + GetManifest(bool considerPins, bool allowSelection = false) : + WorkflowTask("GetManifest"), m_considerPins(considerPins), m_allowSelection(allowSelection) {} void operator()(Execution::Context& context) const override; private: bool m_considerPins; + bool m_allowSelection; }; // Reusable helpers for `show` style line output diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp index 2e8f35100d..c9d72c7994 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp @@ -322,7 +322,7 @@ namespace AppInstaller::CLI::Workflow context << HandleSearchResultFailures << - EnsureOneMatchFromSearchResult(m_operationType) << + EnsureOneMatchFromSearchResult(m_operationType, m_allowSelection) << GetInstalledPackageVersion; if ( m_operationType != OperationType::Upgrade && @@ -372,7 +372,7 @@ namespace AppInstaller::CLI::Workflow context << SearchSourceForSingle << - SelectSinglePackageVersionForInstallOrUpgrade(m_operationType) << + SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, false, m_allowSelection) << InstallSinglePackage; } } diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.h b/src/AppInstallerCLICore/Workflows/UpdateFlow.h index cb84b3452d..bce315b1e9 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.h +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.h @@ -39,14 +39,16 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct SelectSinglePackageVersionForInstallOrUpgrade : public WorkflowTask { - SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, bool allowDowngrade = false) : - WorkflowTask("SelectSinglePackageVersionForInstallOrUpgrade"), m_operationType(operation), m_allowDowngrade(allowDowngrade) {} + SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, bool allowDowngrade = false, bool allowSelection = false) : + WorkflowTask("SelectSinglePackageVersionForInstallOrUpgrade"), m_operationType(operation), m_allowDowngrade(allowDowngrade), + m_allowSelection(allowSelection) {} void operator()(Execution::Context& context) const override; private: mutable OperationType m_operationType; bool m_allowDowngrade; + bool m_allowSelection; }; // Install or upgrade a single package @@ -55,12 +57,13 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct InstallOrUpgradeSinglePackage : public WorkflowTask { - InstallOrUpgradeSinglePackage(OperationType operation) : - WorkflowTask("InstallOrUpgradeSinglePackage"), m_operationType(operation) {} + InstallOrUpgradeSinglePackage(OperationType operation, bool allowSelection = false) : + WorkflowTask("InstallOrUpgradeSinglePackage"), m_operationType(operation), m_allowSelection(allowSelection) {} void operator()(Execution::Context& context) const override; private: mutable OperationType m_operationType; + bool m_allowSelection; }; } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 3a238f57fc..d92f5c6a78 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1396,25 +1396,115 @@ namespace AppInstaller::CLI::Workflow } } + size_t selectedIndex = 0; if (searchResult.Matches.size() > 1) { Logging::Telemetry().LogMultiAppMatch(); - if (operationTargetsInstalled) + bool selectionSupported = m_allowSelection && + (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download); + bool canSelect = selectionSupported && !searchResult.Truncated && + !context.Args.Contains(Execution::Args::Type::Silent) && + IsInteractivityAllowed(context) && context.Reporter.CanPrompt(); + + if (canSelect) + { + auto out = context.Reporter.Info(); + auto title = m_operationType == OperationType::Install ? Resource::String::PackageSelectionInstall : + m_operationType == OperationType::Download ? Resource::String::PackageSelectionDownload : Resource::String::PackageSelectionShow; + out << title << std::endl << std::endl; + + std::vector::line_t> lines; + for (size_t i = 0; i < searchResult.Matches.size(); ++i) + { + auto package = searchResult.Matches[i].Package; + Execution::TableOutput<5>::line_t line{ + std::to_string(i + 1), + package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id), + Resource::LocString{ Resource::String::Unavailable }.get(), + Resource::LocString{ Resource::String::Unavailable }.get() + }; + + auto availablePackages = package->GetAvailable(); + if (availablePackages.empty()) + { + lines.emplace_back(std::move(line)); + } + std::pair previousIdentity; + for (const auto& available : availablePackages) + { + std::pair identity{ + available->GetProperty(PackageProperty::Name), + available->GetProperty(PackageProperty::Id) + }; + bool repeatedIdentity = line[0].empty() && identity == previousIdentity; + line[1] = repeatedIdentity ? ""s : identity.first; + line[2] = repeatedIdentity ? ""s : identity.second; + auto version = available->GetLatestVersion(); + auto source = available->GetSource(); + std::string versionString = version ? version->GetProperty(PackageVersionProperty::Version).get() : std::string{}; + std::string sourceName = source ? source.GetDetails().Name : std::string{}; + line[3] = versionString.empty() ? Resource::LocString{ Resource::String::Unavailable }.get() : versionString; + line[4] = sourceName.empty() ? Resource::LocString{ Resource::String::Unavailable }.get() : sourceName; + lines.emplace_back(line); + line[0].clear(); + previousIdentity = std::move(identity); + } + } + + bool showSource = std::any_of(lines.begin(), lines.end(), [&](const auto& line) { return line[4] != lines.front()[4]; }); + Execution::TableOutput<5> table(context.Reporter, + { + Resource::LocString{ Utility::LocIndString{ "#"sv } }, + Resource::String::SearchName, + Resource::String::SearchId, + Resource::String::SearchVersion, + Resource::String::SearchSource + }); + for (auto& line : lines) + { + if (!showSource) + { + line[4].clear(); + } + table.OutputLine(std::move(line)); + } + table.Complete(); + + out << std::endl; + auto selection = context.Reporter.PromptForSelection(searchResult.Matches.size(), [&]() { return context.IsTerminated(); }); + AICLI_RETURN_IF_TERMINATED(context); + if (!selection) + { + context.Reporter.Info() << Resource::String::Cancelled << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); + } + + selectedIndex = *selection; + auto package = searchResult.Matches[selectedIndex].Package; + out << Resource::String::PackageSelectionSelected(package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id)) << std::endl; + } + else if (operationTargetsInstalled) { context.Reporter.Warn() << Resource::String::MultipleInstalledPackagesFound << std::endl; context << ReportMultiplePackageFoundResult; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); } else { context.Reporter.Warn() << Resource::String::MultiplePackagesFound << std::endl; context << ReportMultiplePackageFoundResultWithSource; + if (selectionSupported) + { + context.Reporter.Info() << Resource::String::PackageSelectionRefine << std::endl; + } + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); } - - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); } - std::shared_ptr package = searchResult.Matches.at(0).Package; + std::shared_ptr package = searchResult.Matches.at(selectedIndex).Package; Logging::Telemetry().LogAppFound(package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id)); context.Add(std::move(package)); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 7d9cba0ae3..f028aa85b5 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -263,13 +263,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: Package struct EnsureOneMatchFromSearchResult : public WorkflowTask { - EnsureOneMatchFromSearchResult(OperationType operation) : - WorkflowTask("EnsureOneMatchFromSearchResult"), m_operationType(operation) {} + EnsureOneMatchFromSearchResult(OperationType operation, bool allowSelection = false) : + WorkflowTask("EnsureOneMatchFromSearchResult"), m_operationType(operation), m_allowSelection(allowSelection) {} void operator()(Execution::Context& context) const override; private: OperationType m_operationType; + bool m_allowSelection; }; // Gets the manifest from package. diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 31d153dffa..da96566576 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -407,6 +407,34 @@ They can be configured through the settings file 'winget settings'. Multiple packages found matching input criteria. Please refine the input. + + Multiple packages match. Choose one to install. + Introduces a numbered list of packages. The user must select a package before installation can continue. + + + Multiple packages match. Choose one to download. + Introduces a numbered list of packages to download, not install. + + + Multiple packages match. Choose one to view. + Introduces a numbered list of packages whose information can be displayed. + + + Enter a number (1-{0}), or 0 to cancel: + {0} is the number of packages in the list. The input is a numeric choice; 0 cancels the command. + + + Invalid selection. Enter a number from 1 to {0}, or 0 to cancel. + {0} is the number of packages in the list. Shown after invalid or empty input. + + + Selected: {0} [{1}] + {0} is the package name and {1} is its identifier. Confirms the user's choice before continuing the original command. + + + Specify a package with --id <ID> --exact --source <SOURCE>. + Guidance when interactive selection is unavailable. Do not translate --id, --exact, or --source. ID and SOURCE are placeholders. + Filter results by name diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index f07e22c45d..44fe262437 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -3,12 +3,319 @@ #include "pch.h" #include "WorkflowCommon.h" #include +#include +#include #include +#include +#include +#include using namespace TestCommon; using namespace AppInstaller::CLI; using namespace AppInstaller::Settings; +TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") +{ + auto response = GENERATE("1", "2", "10", " 2 \t", "0"); + std::istringstream input{ response }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + + auto selection = reporter.PromptForSelection(10); + if (std::string_view{ response } == "0") + { + REQUIRE_FALSE(selection); + } + else + { + REQUIRE(selection == static_cast(std::stoul(response) - 1)); + } + REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(10).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") +{ + auto response = GENERATE("", " ", "-1", "+1", "3", "1x", "1.0", "1 2", "99999999999999999999999999"); + std::istringstream input{ std::string{ response } + "\n2\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + + REQUIRE(reporter.PromptForSelection(2) == 1); + REQUIRE(output.str().find(Resource::String::PackageSelectionInvalid(2).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_InputFailure", "[PackageSelection][PromptFlow]") +{ + std::istringstream input; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + + SECTION("EOF") + { + REQUIRE_THROWS_HR(reporter.PromptForSelection(2), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); + } + SECTION("Cancelled input failure") + { + int checks = 0; + REQUIRE_FALSE(reporter.PromptForSelection(2, [&]() { return ++checks == 3; })); + } + SECTION("Console read aborts before the signal handler runs") + { + struct AbortedInputBuffer : std::streambuf + { + int_type underflow() override + { + SetLastError(ERROR_OPERATION_ABORTED); + return traits_type::eof(); + } + } buffer; + std::istream abortedInput{ &buffer }; + Execution::Reporter abortedReporter{ output, abortedInput }; + abortedReporter.SetConsoleStreamsForTest(true); + REQUIRE_FALSE(abortedReporter.PromptForSelection(2, []() { return false; })); + } +} + +TEST_CASE("PackageSelection_CancelBeforeRead", "[PackageSelection][PromptFlow]") +{ + auto cancelOnCheck = GENERATE(1, 2, 3, 4); + std::istringstream input{ "invalid\n2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + int checks = 0; + auto isCancelled = [&]() + { + if (++checks == cancelOnCheck) + { + context.Terminate(E_ABORT); + } + return context.IsTerminated(); + }; + + REQUIRE_FALSE(context.Reporter.PromptForSelection(2, isCancelled)); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(input.peek() == (cancelOnCheck >= 3 ? '2' : 'i')); + if (cancelOnCheck == 1) + { + REQUIRE(output.str().empty()); + } +} + +TEST_CASE("PackageSelection_CancelPendingRead", "[PackageSelection][PromptFlow]") +{ + struct PipeInputBuffer : std::streambuf + { + wil::unique_handle ReadHandle; + wil::unique_handle WriteHandle; + wil::unique_event ReadStarted{ wil::EventOptions::ManualReset }; + char Character = 0; + + PipeInputBuffer() + { + THROW_IF_WIN32_BOOL_FALSE(CreatePipe(ReadHandle.put(), WriteHandle.put(), nullptr, 0)); + } + + int_type underflow() override + { + ReadStarted.SetEvent(); + DWORD count = 0; + if (!ReadFile(ReadHandle.get(), &Character, 1, &count, nullptr) || !count) + { + return traits_type::eof(); + } + setg(&Character, &Character, &Character + 1); + return traits_type::to_int_type(Character); + } + }; + + PipeInputBuffer buffer; + std::istream input{ &buffer }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + wil::unique_event finished{ wil::EventOptions::ManualReset }; + bool timedOut = false; + std::thread cancel([&]() + { + if (buffer.ReadStarted.wait(5000)) + { + context.Cancel(AppInstaller::CancelReason::CtrlCSignal); + } + }); + std::thread watchdog([&]() + { + if (!finished.wait(5000)) + { + timedOut = true; + DWORD written = 0; + LOG_IF_WIN32_BOOL_FALSE(WriteFile(buffer.WriteHandle.get(), "\n", 1, &written, nullptr)); + } + }); + auto join = wil::scope_exit([&]() + { + finished.SetEvent(); + cancel.join(); + watchdog.join(); + }); + + REQUIRE_FALSE(context.Reporter.PromptForSelection(2, [&]() { return context.IsTerminated(); })); + finished.SetEvent(); + cancel.join(); + watchdog.join(); + join.release(); + REQUIRE_FALSE(timedOut); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str().find(Resource::String::PackageSelectionInvalid(2).get()) == std::string::npos); +} + +TEST_CASE("PackageSelection_ConsoleStreams", "[PackageSelection][PromptFlow]") +{ + Execution::Reporter reporter; + DWORD mode = 0; + bool consoleStreams = GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) && + GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &mode); + REQUIRE(reporter.CanPrompt() == consoleStreams); + + reporter.SetConsoleStreamsForTest(GENERATE(false, true)); + Execution::Reporter clone{ reporter, Execution::Reporter::clone_t{} }; + REQUIRE(clone.CanPrompt() == reporter.CanPrompt()); +} + +TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow]") +{ + std::istringstream input{ "1\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + + SECTION("Redirected streams") + { + reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden informational output") + { + reporter.SetLevelMask(Execution::Reporter::Level::Info, false); + } + SECTION("Non-output channel") + { + reporter.SetChannel(GENERATE(Execution::Reporter::Channel::Completion, Execution::Reporter::Channel::Json, + Execution::Reporter::Channel::Disabled)); + } + + REQUIRE_FALSE(reporter.CanPrompt()); + REQUIRE_THROWS_HR(reporter.PromptForSelection(2), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); + REQUIRE(input.peek() == '1'); +} + +TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + std::istringstream input{ "0\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestQuery_ReturnTwo.Query); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + + SECTION("Install") + { + context.Args.AddArg(Execution::Args::Type::Force); + InstallCommand({}).Execute(context); + } + SECTION("Show") + { + ShowCommand({}).Execute(context); + } + SECTION("Show versions") + { + context.Args.AddArg(Execution::Args::Type::ListVersions); + ShowCommand({}).Execute(context); + } + SECTION("Download") + { + DownloadCommand({}).Execute(context); + } + + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE_FALSE(context.Contains(Execution::Data::Manifest)); + REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestQuery_ReturnTwo.Query); + context.Args.AddArg(Execution::Args::Type::Version, "1.0.0.0"sv); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + + auto checkSelection = [](TestContext& selectedContext) + { + REQUIRE(selectedContext.Get()->GetProperty(AppInstaller::Repository::PackageProperty::Id) == + "AppInstallerCliTest.TestExeInstaller"); + REQUIRE(selectedContext.Get().Version == "1.0.0.0"); + REQUIRE(selectedContext.Args.GetArg(Execution::Args::Type::Version) == "1.0.0.0"); + selectedContext.Terminate(E_ABORT); + }; + + SECTION("Install") + { + context.Args.AddArg(Execution::Args::Type::Force); + context.Override({ Workflow::InstallSinglePackage, checkSelection, 1 }); + InstallCommand({}).Execute(context); + } + SECTION("Show") + { + context.Override({ Workflow::ShowManifestInfo, checkSelection, 1 }); + ShowCommand({}).Execute(context); + } + SECTION("Download") + { + context.Override({ Workflow::SetDownloadDirectory, checkSelection, 1 }); + DownloadCommand({}).Execute(context); + } + + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][MultiQuery]") +{ + TestUserSettings settings; + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Force); + context.Args.AddArg(Execution::Args::Type::MultiQuery, TSR::TestQuery_ReturnTwo.Query); + context.Args.AddArg(Execution::Args::Type::MultiQuery, "MissingPackage"sv); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + context.Override({ Workflow::GetSearchRequestForSingle, [](TestContext& subContext) + { + subContext.Reporter.SetConsoleStreamsForTest(true); + Workflow::GetSearchRequestForSingle(subContext); + } }); + + InstallCommand({}).Execute(context); + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_NOT_ALL_QUERIES_FOUND_SINGLE); + REQUIRE(input.peek() == '1'); + REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); +} + TEST_CASE("PromptFlow_InteractivityDisabled", "[PromptFlow][workflow]") { TestCommon::TempFile installResultPath("TestExeInstalled.txt"); diff --git a/src/AppInstallerCLITests/SourceFlow.cpp b/src/AppInstallerCLITests/SourceFlow.cpp index d6f4084c26..88c11b4f28 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -111,6 +111,271 @@ TEST_CASE("SourcePriority_SearchResult", "[SourcePriority][workflow]") } } +TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][workflow]") +{ + TestUserSettings settings; + auto width = GENERATE(size_t{20}, size_t{120}); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{width} }; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + std::vector versions{ manifest }; + auto firstSource = std::make_shared(); + auto secondSource = std::make_shared(); + auto lowPriority = std::make_shared(); + firstSource->Details.Name = "FirstSource"; + secondSource->Details.Name = "SecondSource"; + SearchResult result; + result.Matches.emplace_back(TestCompositePackage::Make(versions, firstSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + result.Matches.emplace_back(TestCompositePackage::Make(versions, secondSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + auto expectedPackage = result.Matches[1].Package; + bool expectPrompt = true; + bool expectSource = true; + size_t expectedRows = 2; + + SECTION("Same identity across sources") + { + } + SECTION("Single source") + { + result.Matches[1].Package = TestCompositePackage::Make(versions, firstSource); + expectedPackage = result.Matches[1].Package; + expectSource = false; + } + SECTION("Multiple available sources for a candidate") + { + auto package = TestCompositePackage::Make(versions, firstSource); + package->Available.emplace_back(TestPackage::Make(versions, secondSource)); + result.Matches[0].Package = package; + expectedRows = 3; + } + SECTION("Unique source priority") + { + secondSource->Details.Priority = 1; + expectPrompt = false; + } + SECTION("Priority tie") + { + firstSource->Details.Priority = 1; + secondSource->Details.Priority = 1; + auto excluded = TestCompositePackage::Make(versions, lowPriority); + result.Matches.insert(result.Matches.begin(), ResultMatch{ excluded, + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id } }); + } + SECTION("Single match") + { + result.Matches.erase(result.Matches.begin()); + expectPrompt = false; + } + + std::istringstream input{ "2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, true); + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == expectedPackage); + REQUIRE((output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); + if (expectPrompt) + { + auto tableStart = output.str().find("\n# "); + REQUIRE(tableStart != std::string::npos); + auto tableEnd = output.str().find("\n\n", tableStart + 1); + REQUIRE(tableEnd != std::string::npos); + auto tableText = output.str().substr(tableStart + 1, tableEnd - tableStart - 1); + REQUIRE(tableText.find("\n1 ") != std::string::npos); + REQUIRE(tableText.find("\n2 ") != std::string::npos); + std::istringstream tableStream{ tableText }; + std::string line; + size_t lineCount = 0; + while (std::getline(tableStream, line)) + { + REQUIRE(AppInstaller::Utility::UTF8ColumnWidth(line) < width); + ++lineCount; + } + REQUIRE(lineCount == expectedRows + 2); + if (width == 120) + { + REQUIRE(tableText.find(manifest.DefaultLocalization.Get()) != std::string::npos); + REQUIRE(tableText.find(manifest.Id) != std::string::npos); + REQUIRE(tableText.find(manifest.Version) != std::string::npos); + REQUIRE((tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos) == expectSource); + REQUIRE((tableText.find("FirstSource") != std::string::npos) == expectSource); + REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSource); + } + else + { + REQUIRE(tableText.find("\xE2\x80\xA6") != std::string::npos); + } + } + else + { + REQUIRE(input.peek() == '2'); + } + REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); + REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") +{ + struct PrimaryPackage : TestCompositePackage + { + using TestCompositePackage::TestCompositePackage; + + LocIndString GetProperty(PackageProperty property) const override + { + return Available.at(1)->GetProperty(property); + } + }; + + TestUserSettings settings; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + bool differentName = GENERATE(false, true); + bool differentId = GENERATE(false, true); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + manifest.Id = "Public.App"; + manifest.Version = "1.0"; + manifest.DefaultLocalization.Add("PublicName"); + auto firstSource = std::make_shared(); + auto secondSource = std::make_shared(); + firstSource->Details.Name = "FirstSource"; + secondSource->Details.Name = "SecondSource"; + auto package = std::make_shared(std::vector{ manifest }, firstSource); + + std::string secondName = differentName ? "PrivateName" : "PublicName"; + std::string secondId = differentId ? "Private.App" : "Public.App"; + manifest.Id = secondId; + manifest.Version = "2.0"; + manifest.DefaultLocalization.Add(secondName); + package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); + manifest.Version = "3.0"; + package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); + + SearchResult result; + result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, secondId }); + manifest.Id = "Other.App"; + manifest.Version = "4.0"; + manifest.DefaultLocalization.Add("OtherName"); + result.Matches.emplace_back(TestCompositePackage::Make(std::vector{ manifest }, firstSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, true); + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == package); + auto tableStart = output.str().find("\n# "); + REQUIRE(tableStart != std::string::npos); + auto tableEnd = output.str().find("\n\n", tableStart + 1); + REQUIRE(tableEnd != std::string::npos); + std::istringstream tableStream{ output.str().substr(tableStart + 1, tableEnd - tableStart - 1) }; + std::string line; + REQUIRE(static_cast(std::getline(tableStream, line))); + REQUIRE(static_cast(std::getline(tableStream, line))); + std::vector> expectedRows{ + { "1", "PublicName", "Public.App", "1.0", "FirstSource" }, + differentName || differentId ? std::vector{ secondName, secondId, "2.0", "SecondSource" } : + std::vector{ "2.0", "SecondSource" }, + { "3.0", "SecondSource" }, + { "2", "OtherName", "Other.App", "4.0", "FirstSource" } + }; + for (const auto& expectedRow : expectedRows) + { + REQUIRE(static_cast(std::getline(tableStream, line))); + std::istringstream rowStream{ line }; + std::vector fields; + std::string field; + while (rowStream >> field) + { + fields.emplace_back(std::move(field)); + } + REQUIRE(fields == expectedRow); + } + REQUIRE_FALSE(std::getline(tableStream, line)); + REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); + REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + std::istringstream input{ "2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + auto operation = OperationType::Install; + bool allowSelection = true; + HRESULT expectedError = APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND; + + SECTION("Default workflow") + { + allowSelection = false; + } + SECTION("Context disabled") + { + context.SetFlags(Execution::ContextFlag::DisableInteractivity); + } + SECTION("Argument disabled") + { + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + SECTION("Setting disabled") + { + settings.Set(true); + } + SECTION("Silent") + { + context.Args.AddArg(Execution::Args::Type::Silent); + } + SECTION("Redirected streams") + { + context.Reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden output") + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Disabled); + } + SECTION("Truncated results") + { + result.Truncated = true; + } + SECTION("No matches") + { + result.Matches.clear(); + expectedError = APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND; + } + SECTION("Excluded operation") + { + operation = GENERATE(OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, + OperationType::Export, OperationType::Pin, OperationType::Search, OperationType::List, OperationType::Completion); + } + + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, allowSelection); + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, expectedError); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); +} + TEST_CASE("Search_ManifestResolution_BeforeSourcePriority", "[RestSource][SourcePriority][workflow]") { namespace RepositoryRest = AppInstaller::Repository::Rest; From 1510821c86c65e87defceeebe3243311db5959d3 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:26:22 -0700 Subject: [PATCH 02/36] Remove incorrect truncation guarantee from release notes --- doc/ReleaseNotes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 6db5aef38f..a637530eb2 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -4,7 +4,7 @@ ### Interactive package selection -When multiple packages match a single-package `install`, `show`, or `download` command, WinGet offers a numbered choice in an interactive terminal. Enter a package number to continue or `0` to cancel. Package IDs and sources remain visible without truncation. +When multiple packages match a single-package `install`, `show`, or `download` command, WinGet offers a numbered choice in an interactive terminal. Enter a package number to continue or `0` to cancel. Scripts and other noninteractive callers retain the existing ambiguity error. Use `--id --exact --source ` to select a package explicitly, or `--disable-interactivity` to prevent prompts. From d9263df546b89430790c1e98451a771774aea2ac Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:07:51 -0700 Subject: [PATCH 03/36] Fix spelling --- .github/actions/spelling/allow.txt | 1 + doc/specs/#5345 - Interactive package selection.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 240e0d747c..3f229f99a5 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -22,6 +22,7 @@ aspirational Authenticode AUTOLISTEN azureedge +Bawa binlog binver bstr diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index f42b03f215..3826678259 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -1,5 +1,5 @@ --- -author: AmelBawa-msft, GitHub Copilot +author: AmelBawa-msft created on: 2026-09-28 last updated: 2026-09-28 issue id: 5345 @@ -29,7 +29,7 @@ Display candidates in their existing order with stable, one-based numbers. A val | One match after existing policy | Continue without prompting. | | Multiple matches for single-package `install`, `show`, or `download` | Prompt if eligible, including `show --versions`. | | Truncated results | Retain ambiguity error and request refinement. | -| Invalid or empty input | Explain the valid range and reprompt; no default. | +| Invalid or empty input | Explain the valid range and prompt again; no default. | | `0` | Cancel without acting on a package. | | Ctrl+C | Cancel immediately, including while waiting for input. | | EOF or input failure | Report the existing prompt input error. | From 0ef9010b7f9ba6a1d906edcc796105f0bdf46bc0 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:20:07 -0700 Subject: [PATCH 04/36] Lock package selection tokens against localization --- .../Shared/Strings/en-us/winget.resw | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index da96566576..d96d0e19e4 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -421,19 +421,19 @@ They can be configured through the settings file 'winget settings'. Enter a number (1-{0}), or 0 to cancel: - {0} is the number of packages in the list. The input is a numeric choice; 0 cancels the command. + {Locked="{0}"} {0} is the number of packages in the list. The input is a numeric choice; 0 cancels the command. Invalid selection. Enter a number from 1 to {0}, or 0 to cancel. - {0} is the number of packages in the list. Shown after invalid or empty input. + {Locked="{0}"} {0} is the number of packages in the list. Shown after invalid or empty input. Selected: {0} [{1}] - {0} is the package name and {1} is its identifier. Confirms the user's choice before continuing the original command. + {Locked="{0}","{1}"} {0} is the package name and {1} is its identifier. Confirms the user's choice before continuing the original command. Specify a package with --id <ID> --exact --source <SOURCE>. - Guidance when interactive selection is unavailable. Do not translate --id, --exact, or --source. ID and SOURCE are placeholders. + {Locked="--id","--exact","--source","ID","SOURCE"} Guidance when interactive selection is unavailable. ID and SOURCE are placeholders. Filter results by name From 9b928b9623c79d5ef52d8b7f5a85e4ca8f7765b6 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:09:57 -0700 Subject: [PATCH 05/36] Resolve Unavailable once when building package choices --- src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index d92f5c6a78..2f01344bf8 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1415,6 +1415,7 @@ namespace AppInstaller::CLI::Workflow out << title << std::endl << std::endl; std::vector::line_t> lines; + const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); for (size_t i = 0; i < searchResult.Matches.size(); ++i) { auto package = searchResult.Matches[i].Package; @@ -1422,8 +1423,8 @@ namespace AppInstaller::CLI::Workflow std::to_string(i + 1), package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id), - Resource::LocString{ Resource::String::Unavailable }.get(), - Resource::LocString{ Resource::String::Unavailable }.get() + unavailable, + unavailable }; auto availablePackages = package->GetAvailable(); @@ -1445,8 +1446,8 @@ namespace AppInstaller::CLI::Workflow auto source = available->GetSource(); std::string versionString = version ? version->GetProperty(PackageVersionProperty::Version).get() : std::string{}; std::string sourceName = source ? source.GetDetails().Name : std::string{}; - line[3] = versionString.empty() ? Resource::LocString{ Resource::String::Unavailable }.get() : versionString; - line[4] = sourceName.empty() ? Resource::LocString{ Resource::String::Unavailable }.get() : sourceName; + line[3] = versionString.empty() ? unavailable : versionString; + line[4] = sourceName.empty() ? unavailable : sourceName; lines.emplace_back(line); line[0].clear(); previousIdentity = std::move(identity); From b76321caab1622cae24392fbf76a82f1076528ba Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:19:34 -0700 Subject: [PATCH 06/36] Always show sources in interactive package selection --- doc/specs/#5345 - Interactive package selection.md | 12 ++++++------ src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 5 ----- src/AppInstallerCLITests/SourceFlow.cpp | 10 +++++----- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index 3826678259..9641ef782e 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -1,7 +1,7 @@ --- author: AmelBawa-msft created on: 2026-09-28 -last updated: 2026-09-28 +last updated: 2026-09-29 issue id: 5345 --- @@ -43,15 +43,15 @@ The prompt adds no settings, command-line flags, group policies, manifest fields ## UI/UX Design -Use the existing search table layout with a leading selection number. Show the Source column only when candidates span sources. For example: +Use the existing search table layout with a leading selection number. Always show the Source column, including when all candidates use the same source. For example: ```text Multiple packages match. Choose one to install. -# Name Id Version -------------------------------------------- -1 Contoso Editor Contoso.Editor 2.4.0 -2 Contoso Editor Contoso.Editor.Pro 2.4.0 +# Name Id Version Source +-------------------------------------------------- +1 Contoso Editor Contoso.Editor 2.4.0 winget +2 Contoso Editor Contoso.Editor.Pro 2.4.0 winget Enter a number (1-2), or 0 to cancel: 1 Selected: Contoso Editor [Contoso.Editor] diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 2f01344bf8..e08c7e11b4 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1454,7 +1454,6 @@ namespace AppInstaller::CLI::Workflow } } - bool showSource = std::any_of(lines.begin(), lines.end(), [&](const auto& line) { return line[4] != lines.front()[4]; }); Execution::TableOutput<5> table(context.Reporter, { Resource::LocString{ Utility::LocIndString{ "#"sv } }, @@ -1465,10 +1464,6 @@ namespace AppInstaller::CLI::Workflow }); for (auto& line : lines) { - if (!showSource) - { - line[4].clear(); - } table.OutputLine(std::move(line)); } table.Complete(); diff --git a/src/AppInstallerCLITests/SourceFlow.cpp b/src/AppInstallerCLITests/SourceFlow.cpp index 88c11b4f28..7b067d698b 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -131,7 +131,7 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); auto expectedPackage = result.Matches[1].Package; bool expectPrompt = true; - bool expectSource = true; + bool expectSecondSource = true; size_t expectedRows = 2; SECTION("Same identity across sources") @@ -141,7 +141,7 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w { result.Matches[1].Package = TestCompositePackage::Make(versions, firstSource); expectedPackage = result.Matches[1].Package; - expectSource = false; + expectSecondSource = false; } SECTION("Multiple available sources for a candidate") { @@ -205,9 +205,9 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w REQUIRE(tableText.find(manifest.DefaultLocalization.Get()) != std::string::npos); REQUIRE(tableText.find(manifest.Id) != std::string::npos); REQUIRE(tableText.find(manifest.Version) != std::string::npos); - REQUIRE((tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos) == expectSource); - REQUIRE((tableText.find("FirstSource") != std::string::npos) == expectSource); - REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSource); + REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos); + REQUIRE(tableText.find("FirstSource") != std::string::npos); + REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSecondSource); } else { From 5c4e8a6b1c8937b2c958ccbe8804431e0b493f00 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:21:47 -0700 Subject: [PATCH 07/36] Move package selection tests alongside prompt workflows --- src/AppInstallerCLITests/PromptFlow.cpp | 269 ++++++++++++++++++++++++ src/AppInstallerCLITests/SourceFlow.cpp | 265 ----------------------- 2 files changed, 269 insertions(+), 265 deletions(-) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 44fe262437..c75b97b585 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "WorkflowCommon.h" +#include "TestHooks.h" #include #include #include @@ -9,9 +10,12 @@ #include #include #include +#include using namespace TestCommon; using namespace AppInstaller::CLI; +using namespace AppInstaller::CLI::Workflow; +using namespace AppInstaller::Repository; using namespace AppInstaller::Settings; TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") @@ -316,6 +320,271 @@ TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][Mult REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); } +TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][workflow]") +{ + TestUserSettings settings; + auto width = GENERATE(size_t{20}, size_t{120}); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{width} }; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + std::vector versions{ manifest }; + auto firstSource = std::make_shared(); + auto secondSource = std::make_shared(); + auto lowPriority = std::make_shared(); + firstSource->Details.Name = "FirstSource"; + secondSource->Details.Name = "SecondSource"; + SearchResult result; + result.Matches.emplace_back(TestCompositePackage::Make(versions, firstSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + result.Matches.emplace_back(TestCompositePackage::Make(versions, secondSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + auto expectedPackage = result.Matches[1].Package; + bool expectPrompt = true; + bool expectSecondSource = true; + size_t expectedRows = 2; + + SECTION("Same identity across sources") + { + } + SECTION("Single source") + { + result.Matches[1].Package = TestCompositePackage::Make(versions, firstSource); + expectedPackage = result.Matches[1].Package; + expectSecondSource = false; + } + SECTION("Multiple available sources for a candidate") + { + auto package = TestCompositePackage::Make(versions, firstSource); + package->Available.emplace_back(TestPackage::Make(versions, secondSource)); + result.Matches[0].Package = package; + expectedRows = 3; + } + SECTION("Unique source priority") + { + secondSource->Details.Priority = 1; + expectPrompt = false; + } + SECTION("Priority tie") + { + firstSource->Details.Priority = 1; + secondSource->Details.Priority = 1; + auto excluded = TestCompositePackage::Make(versions, lowPriority); + result.Matches.insert(result.Matches.begin(), ResultMatch{ excluded, + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id } }); + } + SECTION("Single match") + { + result.Matches.erase(result.Matches.begin()); + expectPrompt = false; + } + + std::istringstream input{ "2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, true); + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == expectedPackage); + REQUIRE((output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); + if (expectPrompt) + { + auto tableStart = output.str().find("\n# "); + REQUIRE(tableStart != std::string::npos); + auto tableEnd = output.str().find("\n\n", tableStart + 1); + REQUIRE(tableEnd != std::string::npos); + auto tableText = output.str().substr(tableStart + 1, tableEnd - tableStart - 1); + REQUIRE(tableText.find("\n1 ") != std::string::npos); + REQUIRE(tableText.find("\n2 ") != std::string::npos); + std::istringstream tableStream{ tableText }; + std::string line; + size_t lineCount = 0; + while (std::getline(tableStream, line)) + { + REQUIRE(AppInstaller::Utility::UTF8ColumnWidth(line) < width); + ++lineCount; + } + REQUIRE(lineCount == expectedRows + 2); + if (width == 120) + { + REQUIRE(tableText.find(manifest.DefaultLocalization.Get()) != std::string::npos); + REQUIRE(tableText.find(manifest.Id) != std::string::npos); + REQUIRE(tableText.find(manifest.Version) != std::string::npos); + REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos); + REQUIRE(tableText.find("FirstSource") != std::string::npos); + REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSecondSource); + } + else + { + REQUIRE(tableText.find("\xE2\x80\xA6") != std::string::npos); + } + } + else + { + REQUIRE(input.peek() == '2'); + } + REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); + REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") +{ + struct PrimaryPackage : TestCompositePackage + { + using TestCompositePackage::TestCompositePackage; + + LocIndString GetProperty(PackageProperty property) const override + { + return Available.at(1)->GetProperty(property); + } + }; + + TestUserSettings settings; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + bool differentName = GENERATE(false, true); + bool differentId = GENERATE(false, true); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + manifest.Id = "Public.App"; + manifest.Version = "1.0"; + manifest.DefaultLocalization.Add("PublicName"); + auto firstSource = std::make_shared(); + auto secondSource = std::make_shared(); + firstSource->Details.Name = "FirstSource"; + secondSource->Details.Name = "SecondSource"; + auto package = std::make_shared(std::vector{ manifest }, firstSource); + + std::string secondName = differentName ? "PrivateName" : "PublicName"; + std::string secondId = differentId ? "Private.App" : "Public.App"; + manifest.Id = secondId; + manifest.Version = "2.0"; + manifest.DefaultLocalization.Add(secondName); + package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); + manifest.Version = "3.0"; + package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); + + SearchResult result; + result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, secondId }); + manifest.Id = "Other.App"; + manifest.Version = "4.0"; + manifest.DefaultLocalization.Add("OtherName"); + result.Matches.emplace_back(TestCompositePackage::Make(std::vector{ manifest }, firstSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, true); + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == package); + auto tableStart = output.str().find("\n# "); + REQUIRE(tableStart != std::string::npos); + auto tableEnd = output.str().find("\n\n", tableStart + 1); + REQUIRE(tableEnd != std::string::npos); + std::istringstream tableStream{ output.str().substr(tableStart + 1, tableEnd - tableStart - 1) }; + std::string line; + REQUIRE(static_cast(std::getline(tableStream, line))); + REQUIRE(static_cast(std::getline(tableStream, line))); + std::vector> expectedRows{ + { "1", "PublicName", "Public.App", "1.0", "FirstSource" }, + differentName || differentId ? std::vector{ secondName, secondId, "2.0", "SecondSource" } : + std::vector{ "2.0", "SecondSource" }, + { "3.0", "SecondSource" }, + { "2", "OtherName", "Other.App", "4.0", "FirstSource" } + }; + for (const auto& expectedRow : expectedRows) + { + REQUIRE(static_cast(std::getline(tableStream, line))); + std::istringstream rowStream{ line }; + std::vector fields; + std::string field; + while (rowStream >> field) + { + fields.emplace_back(std::move(field)); + } + REQUIRE(fields == expectedRow); + } + REQUIRE_FALSE(std::getline(tableStream, line)); + REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); + REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + std::istringstream input{ "2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + auto operation = OperationType::Install; + bool allowSelection = true; + HRESULT expectedError = APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND; + + SECTION("Default workflow") + { + allowSelection = false; + } + SECTION("Context disabled") + { + context.SetFlags(Execution::ContextFlag::DisableInteractivity); + } + SECTION("Argument disabled") + { + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + SECTION("Setting disabled") + { + settings.Set(true); + } + SECTION("Silent") + { + context.Args.AddArg(Execution::Args::Type::Silent); + } + SECTION("Redirected streams") + { + context.Reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden output") + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Disabled); + } + SECTION("Truncated results") + { + result.Truncated = true; + } + SECTION("No matches") + { + result.Matches.clear(); + expectedError = APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND; + } + SECTION("Excluded operation") + { + operation = GENERATE(OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, + OperationType::Export, OperationType::Pin, OperationType::Search, OperationType::List, OperationType::Completion); + } + + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, allowSelection); + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, expectedError); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); +} + TEST_CASE("PromptFlow_InteractivityDisabled", "[PromptFlow][workflow]") { TestCommon::TempFile installResultPath("TestExeInstalled.txt"); diff --git a/src/AppInstallerCLITests/SourceFlow.cpp b/src/AppInstallerCLITests/SourceFlow.cpp index 7b067d698b..d6f4084c26 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -111,271 +111,6 @@ TEST_CASE("SourcePriority_SearchResult", "[SourcePriority][workflow]") } } -TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][workflow]") -{ - TestUserSettings settings; - auto width = GENERATE(size_t{20}, size_t{120}); - TestHook::SetConsoleWidth_Override widthOverride{ std::optional{width} }; - auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); - auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); - std::vector versions{ manifest }; - auto firstSource = std::make_shared(); - auto secondSource = std::make_shared(); - auto lowPriority = std::make_shared(); - firstSource->Details.Name = "FirstSource"; - secondSource->Details.Name = "SecondSource"; - SearchResult result; - result.Matches.emplace_back(TestCompositePackage::Make(versions, firstSource), - PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); - result.Matches.emplace_back(TestCompositePackage::Make(versions, secondSource), - PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); - auto expectedPackage = result.Matches[1].Package; - bool expectPrompt = true; - bool expectSecondSource = true; - size_t expectedRows = 2; - - SECTION("Same identity across sources") - { - } - SECTION("Single source") - { - result.Matches[1].Package = TestCompositePackage::Make(versions, firstSource); - expectedPackage = result.Matches[1].Package; - expectSecondSource = false; - } - SECTION("Multiple available sources for a candidate") - { - auto package = TestCompositePackage::Make(versions, firstSource); - package->Available.emplace_back(TestPackage::Make(versions, secondSource)); - result.Matches[0].Package = package; - expectedRows = 3; - } - SECTION("Unique source priority") - { - secondSource->Details.Priority = 1; - expectPrompt = false; - } - SECTION("Priority tie") - { - firstSource->Details.Priority = 1; - secondSource->Details.Priority = 1; - auto excluded = TestCompositePackage::Make(versions, lowPriority); - result.Matches.insert(result.Matches.begin(), ResultMatch{ excluded, - PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id } }); - } - SECTION("Single match") - { - result.Matches.erase(result.Matches.begin()); - expectPrompt = false; - } - - std::istringstream input{ "2\n" }; - std::ostringstream output; - TestContext context{ output, input }; - auto previousThreadGlobals = context.SetForCurrentThread(); - context.Reporter.SetConsoleStreamsForTest(true); - context.Reporter.SetStyle(VisualStyle::NoVT); - context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, true); - - INFO(output.str()); - REQUIRE_FALSE(context.IsTerminated()); - REQUIRE(context.Get() == expectedPackage); - REQUIRE((output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); - if (expectPrompt) - { - auto tableStart = output.str().find("\n# "); - REQUIRE(tableStart != std::string::npos); - auto tableEnd = output.str().find("\n\n", tableStart + 1); - REQUIRE(tableEnd != std::string::npos); - auto tableText = output.str().substr(tableStart + 1, tableEnd - tableStart - 1); - REQUIRE(tableText.find("\n1 ") != std::string::npos); - REQUIRE(tableText.find("\n2 ") != std::string::npos); - std::istringstream tableStream{ tableText }; - std::string line; - size_t lineCount = 0; - while (std::getline(tableStream, line)) - { - REQUIRE(AppInstaller::Utility::UTF8ColumnWidth(line) < width); - ++lineCount; - } - REQUIRE(lineCount == expectedRows + 2); - if (width == 120) - { - REQUIRE(tableText.find(manifest.DefaultLocalization.Get()) != std::string::npos); - REQUIRE(tableText.find(manifest.Id) != std::string::npos); - REQUIRE(tableText.find(manifest.Version) != std::string::npos); - REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos); - REQUIRE(tableText.find("FirstSource") != std::string::npos); - REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSecondSource); - } - else - { - REQUIRE(tableText.find("\xE2\x80\xA6") != std::string::npos); - } - } - else - { - REQUIRE(input.peek() == '2'); - } - REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); - REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); -} - -TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") -{ - struct PrimaryPackage : TestCompositePackage - { - using TestCompositePackage::TestCompositePackage; - - LocIndString GetProperty(PackageProperty property) const override - { - return Available.at(1)->GetProperty(property); - } - }; - - TestUserSettings settings; - TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; - auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); - bool differentName = GENERATE(false, true); - bool differentId = GENERATE(false, true); - auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); - manifest.Id = "Public.App"; - manifest.Version = "1.0"; - manifest.DefaultLocalization.Add("PublicName"); - auto firstSource = std::make_shared(); - auto secondSource = std::make_shared(); - firstSource->Details.Name = "FirstSource"; - secondSource->Details.Name = "SecondSource"; - auto package = std::make_shared(std::vector{ manifest }, firstSource); - - std::string secondName = differentName ? "PrivateName" : "PublicName"; - std::string secondId = differentId ? "Private.App" : "Public.App"; - manifest.Id = secondId; - manifest.Version = "2.0"; - manifest.DefaultLocalization.Add(secondName); - package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); - manifest.Version = "3.0"; - package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); - - SearchResult result; - result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, secondId }); - manifest.Id = "Other.App"; - manifest.Version = "4.0"; - manifest.DefaultLocalization.Add("OtherName"); - result.Matches.emplace_back(TestCompositePackage::Make(std::vector{ manifest }, firstSource), - PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); - - std::istringstream input{ "1\n" }; - std::ostringstream output; - TestContext context{ output, input }; - auto previousThreadGlobals = context.SetForCurrentThread(); - context.Reporter.SetConsoleStreamsForTest(true); - context.Reporter.SetStyle(VisualStyle::NoVT); - context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, true); - - INFO(output.str()); - REQUIRE_FALSE(context.IsTerminated()); - REQUIRE(context.Get() == package); - auto tableStart = output.str().find("\n# "); - REQUIRE(tableStart != std::string::npos); - auto tableEnd = output.str().find("\n\n", tableStart + 1); - REQUIRE(tableEnd != std::string::npos); - std::istringstream tableStream{ output.str().substr(tableStart + 1, tableEnd - tableStart - 1) }; - std::string line; - REQUIRE(static_cast(std::getline(tableStream, line))); - REQUIRE(static_cast(std::getline(tableStream, line))); - std::vector> expectedRows{ - { "1", "PublicName", "Public.App", "1.0", "FirstSource" }, - differentName || differentId ? std::vector{ secondName, secondId, "2.0", "SecondSource" } : - std::vector{ "2.0", "SecondSource" }, - { "3.0", "SecondSource" }, - { "2", "OtherName", "Other.App", "4.0", "FirstSource" } - }; - for (const auto& expectedRow : expectedRows) - { - REQUIRE(static_cast(std::getline(tableStream, line))); - std::istringstream rowStream{ line }; - std::vector fields; - std::string field; - while (rowStream >> field) - { - fields.emplace_back(std::move(field)); - } - REQUIRE(fields == expectedRow); - } - REQUIRE_FALSE(std::getline(tableStream, line)); - REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); - REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); -} - -TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") -{ - TestUserSettings settings; - std::istringstream input{ "2\n" }; - std::ostringstream output; - TestContext context{ output, input }; - auto previousThreadGlobals = context.SetForCurrentThread(); - context.Reporter.SetConsoleStreamsForTest(true); - auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); - auto result = source->Search({}); - auto operation = OperationType::Install; - bool allowSelection = true; - HRESULT expectedError = APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND; - - SECTION("Default workflow") - { - allowSelection = false; - } - SECTION("Context disabled") - { - context.SetFlags(Execution::ContextFlag::DisableInteractivity); - } - SECTION("Argument disabled") - { - context.Args.AddArg(Execution::Args::Type::DisableInteractivity); - } - SECTION("Setting disabled") - { - settings.Set(true); - } - SECTION("Silent") - { - context.Args.AddArg(Execution::Args::Type::Silent); - } - SECTION("Redirected streams") - { - context.Reporter.SetConsoleStreamsForTest(false); - } - SECTION("Hidden output") - { - context.Reporter.SetChannel(Execution::Reporter::Channel::Disabled); - } - SECTION("Truncated results") - { - result.Truncated = true; - } - SECTION("No matches") - { - result.Matches.clear(); - expectedError = APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND; - } - SECTION("Excluded operation") - { - operation = GENERATE(OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, - OperationType::Export, OperationType::Pin, OperationType::Search, OperationType::List, OperationType::Completion); - } - - context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, allowSelection); - INFO(output.str()); - REQUIRE_TERMINATED_WITH(context, expectedError); - REQUIRE_FALSE(context.Contains(Execution::Data::Package)); - REQUIRE(input.peek() == '2'); - REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); -} - TEST_CASE("Search_ManifestResolution_BeforeSourcePriority", "[RestSource][SourcePriority][workflow]") { namespace RepositoryRest = AppInstaller::Repository::Rest; From ff053c7cdde787d99ab60896ce9a7b17813614ad Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:37:21 -0700 Subject: [PATCH 08/36] Require selection argument before optional downgrade flag --- src/AppInstallerCLICore/Commands/DscPackageResource.cpp | 2 +- src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp | 2 +- src/AppInstallerCLICore/Workflows/UpdateFlow.cpp | 2 +- src/AppInstallerCLICore/Workflows/UpdateFlow.h | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AppInstallerCLICore/Commands/DscPackageResource.cpp b/src/AppInstallerCLICore/Commands/DscPackageResource.cpp index d43aac207d..7b0c5eaad1 100644 --- a/src/AppInstallerCLICore/Commands/DscPackageResource.cpp +++ b/src/AppInstallerCLICore/Commands/DscPackageResource.cpp @@ -237,7 +237,7 @@ namespace AppInstaller::CLI } *SubContext << - Workflow::SelectSinglePackageVersionForInstallOrUpgrade(Workflow::OperationType::Install, allowDowngrade) << + Workflow::SelectSinglePackageVersionForInstallOrUpgrade(Workflow::OperationType::Install, false, allowDowngrade) << Workflow::InstallSinglePackage; if (SubContext->IsTerminated()) diff --git a/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp b/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp index c673b13f3d..b83c3b23e9 100644 --- a/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp @@ -71,7 +71,7 @@ namespace AppInstaller::CLI::Workflow { case OperationType::Install: case OperationType::Upgrade: - searchContext << Workflow::SelectSinglePackageVersionForInstallOrUpgrade(m_operationType); + searchContext << Workflow::SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, false); break; case OperationType::Uninstall: searchContext << diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp index c9d72c7994..c68f78138b 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp @@ -372,7 +372,7 @@ namespace AppInstaller::CLI::Workflow context << SearchSourceForSingle << - SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, false, m_allowSelection) << + SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, m_allowSelection) << InstallSinglePackage; } } diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.h b/src/AppInstallerCLICore/Workflows/UpdateFlow.h index bce315b1e9..1eb37439b5 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.h +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.h @@ -39,7 +39,7 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct SelectSinglePackageVersionForInstallOrUpgrade : public WorkflowTask { - SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, bool allowDowngrade = false, bool allowSelection = false) : + SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, bool allowSelection, bool allowDowngrade = false) : WorkflowTask("SelectSinglePackageVersionForInstallOrUpgrade"), m_operationType(operation), m_allowDowngrade(allowDowngrade), m_allowSelection(allowSelection) {} From 9a8cef7d250a276f3c704cdf51b100f6002ce437 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:38:39 -0700 Subject: [PATCH 09/36] Replace package selection booleans with an explicit enum --- src/AppInstallerCLICore/Commands/DownloadCommand.cpp | 2 +- .../Commands/DscPackageResource.cpp | 2 +- src/AppInstallerCLICore/Commands/InstallCommand.cpp | 2 +- src/AppInstallerCLICore/Commands/ShowCommand.cpp | 4 ++-- src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp | 2 +- src/AppInstallerCLICore/Workflows/ShowFlow.cpp | 2 +- src/AppInstallerCLICore/Workflows/ShowFlow.h | 6 +++--- src/AppInstallerCLICore/Workflows/UpdateFlow.cpp | 4 ++-- src/AppInstallerCLICore/Workflows/UpdateFlow.h | 12 ++++++------ src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 2 +- src/AppInstallerCLICore/Workflows/WorkflowBase.h | 12 +++++++++--- src/AppInstallerCLITests/PromptFlow.cpp | 10 +++++----- 12 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/AppInstallerCLICore/Commands/DownloadCommand.cpp b/src/AppInstallerCLICore/Commands/DownloadCommand.cpp index 8ae798c512..7ee7a857af 100644 --- a/src/AppInstallerCLICore/Commands/DownloadCommand.cpp +++ b/src/AppInstallerCLICore/Commands/DownloadCommand.cpp @@ -126,7 +126,7 @@ namespace AppInstaller::CLI Workflow::OpenSource() << Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Download, true) << + Workflow::EnsureOneMatchFromSearchResult(OperationType::Download, PackageSelectionBehavior::Prompt) << Workflow::GetManifestFromPackage(false); } diff --git a/src/AppInstallerCLICore/Commands/DscPackageResource.cpp b/src/AppInstallerCLICore/Commands/DscPackageResource.cpp index 7b0c5eaad1..c45dfb2e4f 100644 --- a/src/AppInstallerCLICore/Commands/DscPackageResource.cpp +++ b/src/AppInstallerCLICore/Commands/DscPackageResource.cpp @@ -237,7 +237,7 @@ namespace AppInstaller::CLI } *SubContext << - Workflow::SelectSinglePackageVersionForInstallOrUpgrade(Workflow::OperationType::Install, false, allowDowngrade) << + Workflow::SelectSinglePackageVersionForInstallOrUpgrade(Workflow::OperationType::Install, Workflow::PackageSelectionBehavior::Disabled, allowDowngrade) << Workflow::InstallSinglePackage; if (SubContext->IsTerminated()) diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp index 9ea50d0303..9d61c1235c 100644 --- a/src/AppInstallerCLICore/Commands/InstallCommand.cpp +++ b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -167,7 +167,7 @@ namespace AppInstaller::CLI { context << Checkpoint("PreInstallCheckpoint", {}) << // TODO: Capture context data - InstallOrUpgradeSinglePackage(OperationType::Install, true); + InstallOrUpgradeSinglePackage(OperationType::Install, PackageSelectionBehavior::Prompt); } } } diff --git a/src/AppInstallerCLICore/Commands/ShowCommand.cpp b/src/AppInstallerCLICore/Commands/ShowCommand.cpp index 8522484d8a..c2fff50284 100644 --- a/src/AppInstallerCLICore/Commands/ShowCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ShowCommand.cpp @@ -93,7 +93,7 @@ namespace AppInstaller::CLI Workflow::OpenSource() << Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Show, true) << + Workflow::EnsureOneMatchFromSearchResult(OperationType::Show, PackageSelectionBehavior::Prompt) << Workflow::ReportPackageIdentity << Workflow::ShowAppVersions; } @@ -101,7 +101,7 @@ namespace AppInstaller::CLI else { context << - GetManifest(false, true) << + GetManifest(false, PackageSelectionBehavior::Prompt) << Workflow::ReportManifestIdentity << Workflow::SelectInstaller << Workflow::ShowManifestInfo; diff --git a/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp b/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp index b83c3b23e9..bb26671cc6 100644 --- a/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp @@ -71,7 +71,7 @@ namespace AppInstaller::CLI::Workflow { case OperationType::Install: case OperationType::Upgrade: - searchContext << Workflow::SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, false); + searchContext << Workflow::SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, PackageSelectionBehavior::Disabled); break; case OperationType::Uninstall: searchContext << diff --git a/src/AppInstallerCLICore/Workflows/ShowFlow.cpp b/src/AppInstallerCLICore/Workflows/ShowFlow.cpp index fa6a230020..c465225a2f 100644 --- a/src/AppInstallerCLICore/Workflows/ShowFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/ShowFlow.cpp @@ -226,7 +226,7 @@ namespace AppInstaller::CLI::Workflow OpenSource() << SearchSourceForSingle << HandleSearchResultFailures << - EnsureOneMatchFromSearchResult(OperationType::Show, m_allowSelection) << + EnsureOneMatchFromSearchResult(OperationType::Show, m_selectionBehavior) << GetManifestFromPackage(m_considerPins); } } diff --git a/src/AppInstallerCLICore/Workflows/ShowFlow.h b/src/AppInstallerCLICore/Workflows/ShowFlow.h index 055c975417..6992adf387 100644 --- a/src/AppInstallerCLICore/Workflows/ShowFlow.h +++ b/src/AppInstallerCLICore/Workflows/ShowFlow.h @@ -44,14 +44,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: Manifest struct GetManifest : public WorkflowTask { - GetManifest(bool considerPins, bool allowSelection = false) : - WorkflowTask("GetManifest"), m_considerPins(considerPins), m_allowSelection(allowSelection) {} + GetManifest(bool considerPins, PackageSelectionBehavior selectionBehavior = PackageSelectionBehavior::Disabled) : + WorkflowTask("GetManifest"), m_considerPins(considerPins), m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: bool m_considerPins; - bool m_allowSelection; + PackageSelectionBehavior m_selectionBehavior; }; // Reusable helpers for `show` style line output diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp index c68f78138b..a2c243673e 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp @@ -322,7 +322,7 @@ namespace AppInstaller::CLI::Workflow context << HandleSearchResultFailures << - EnsureOneMatchFromSearchResult(m_operationType, m_allowSelection) << + EnsureOneMatchFromSearchResult(m_operationType, m_selectionBehavior) << GetInstalledPackageVersion; if ( m_operationType != OperationType::Upgrade && @@ -372,7 +372,7 @@ namespace AppInstaller::CLI::Workflow context << SearchSourceForSingle << - SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, m_allowSelection) << + SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, m_selectionBehavior) << InstallSinglePackage; } } diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.h b/src/AppInstallerCLICore/Workflows/UpdateFlow.h index 1eb37439b5..0d686f6483 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.h +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.h @@ -39,16 +39,16 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct SelectSinglePackageVersionForInstallOrUpgrade : public WorkflowTask { - SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, bool allowSelection, bool allowDowngrade = false) : + SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, PackageSelectionBehavior selectionBehavior, bool allowDowngrade = false) : WorkflowTask("SelectSinglePackageVersionForInstallOrUpgrade"), m_operationType(operation), m_allowDowngrade(allowDowngrade), - m_allowSelection(allowSelection) {} + m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: mutable OperationType m_operationType; bool m_allowDowngrade; - bool m_allowSelection; + PackageSelectionBehavior m_selectionBehavior; }; // Install or upgrade a single package @@ -57,13 +57,13 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct InstallOrUpgradeSinglePackage : public WorkflowTask { - InstallOrUpgradeSinglePackage(OperationType operation, bool allowSelection = false) : - WorkflowTask("InstallOrUpgradeSinglePackage"), m_operationType(operation), m_allowSelection(allowSelection) {} + InstallOrUpgradeSinglePackage(OperationType operation, PackageSelectionBehavior selectionBehavior = PackageSelectionBehavior::Disabled) : + WorkflowTask("InstallOrUpgradeSinglePackage"), m_operationType(operation), m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: mutable OperationType m_operationType; - bool m_allowSelection; + PackageSelectionBehavior m_selectionBehavior; }; } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index e08c7e11b4..7591682d71 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1401,7 +1401,7 @@ namespace AppInstaller::CLI::Workflow { Logging::Telemetry().LogMultiAppMatch(); - bool selectionSupported = m_allowSelection && + bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download); bool canSelect = selectionSupported && !searchResult.Truncated && !context.Args.Contains(Execution::Args::Type::Silent) && diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index f028aa85b5..e0a74bf441 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -46,6 +46,12 @@ namespace AppInstaller::CLI::Workflow Repair, }; + enum class PackageSelectionBehavior + { + Disabled, + Prompt, + }; + // A task in the workflow. struct WorkflowTask { @@ -263,14 +269,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: Package struct EnsureOneMatchFromSearchResult : public WorkflowTask { - EnsureOneMatchFromSearchResult(OperationType operation, bool allowSelection = false) : - WorkflowTask("EnsureOneMatchFromSearchResult"), m_operationType(operation), m_allowSelection(allowSelection) {} + EnsureOneMatchFromSearchResult(OperationType operation, PackageSelectionBehavior selectionBehavior = PackageSelectionBehavior::Disabled) : + WorkflowTask("EnsureOneMatchFromSearchResult"), m_operationType(operation), m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: OperationType m_operationType; - bool m_allowSelection; + PackageSelectionBehavior m_selectionBehavior; }; // Gets the manifest from package. diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index c75b97b585..c55d30fa28 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -385,7 +385,7 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w context.Reporter.SetConsoleStreamsForTest(true); context.Reporter.SetStyle(VisualStyle::NoVT); context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, true); + context << EnsureOneMatchFromSearchResult(operation, PackageSelectionBehavior::Prompt); INFO(output.str()); REQUIRE_FALSE(context.IsTerminated()); @@ -482,7 +482,7 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") context.Reporter.SetConsoleStreamsForTest(true); context.Reporter.SetStyle(VisualStyle::NoVT); context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, true); + context << EnsureOneMatchFromSearchResult(operation, PackageSelectionBehavior::Prompt); INFO(output.str()); REQUIRE_FALSE(context.IsTerminated()); @@ -530,12 +530,12 @@ TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); auto result = source->Search({}); auto operation = OperationType::Install; - bool allowSelection = true; + auto selectionBehavior = PackageSelectionBehavior::Prompt; HRESULT expectedError = APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND; SECTION("Default workflow") { - allowSelection = false; + selectionBehavior = PackageSelectionBehavior::Disabled; } SECTION("Context disabled") { @@ -577,7 +577,7 @@ TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") } context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, allowSelection); + context << EnsureOneMatchFromSearchResult(operation, selectionBehavior); INFO(output.str()); REQUIRE_TERMINATED_WITH(context, expectedError); REQUIRE_FALSE(context.Contains(Execution::Data::Package)); From 6b35743201e694bd87b1fc360eac30bfc07c7821 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:16:56 -0700 Subject: [PATCH 10/36] Restore considerPins argument comment in show command --- src/AppInstallerCLICore/Commands/ShowCommand.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AppInstallerCLICore/Commands/ShowCommand.cpp b/src/AppInstallerCLICore/Commands/ShowCommand.cpp index c2fff50284..4813195f8e 100644 --- a/src/AppInstallerCLICore/Commands/ShowCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ShowCommand.cpp @@ -101,7 +101,7 @@ namespace AppInstaller::CLI else { context << - GetManifest(false, PackageSelectionBehavior::Prompt) << + GetManifest( /* considerPins */ false, PackageSelectionBehavior::Prompt) << Workflow::ReportManifestIdentity << Workflow::SelectInstaller << Workflow::ShowManifestInfo; From 42044ef798cb2a87c2636c18b4fba446f7dcfda3 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:25:09 -0700 Subject: [PATCH 11/36] Use one-indexed wording in the package selection spec --- doc/specs/#5345 - Interactive package selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index 9641ef782e..9c953303bb 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -21,7 +21,7 @@ The same query can match multiple packages, including packages from different so Apply existing search matching and source-priority rules first. If multiple candidates remain, eligible CLI call sites opt into selection. Shared workflows remain noninteractive by default. -Display candidates in their existing order with stable, one-based numbers. A valid number selects the existing package object without searching again. Preserve command options and continue normal version selection, applicability checks, and agreement handling. +Display candidates in their existing order with stable, one-indexed numbers. A valid number selects the existing package object without searching again. Preserve command options and continue normal version selection, applicability checks, and agreement handling. | Situation | Behavior | | --- | --- | From a3146a5ef0e8cbbd654ede2e83fb6ec4d2b56e66 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:28:16 -0700 Subject: [PATCH 12/36] Clarify that multi-package flows do not prompt for selection --- doc/specs/#5345 - Interactive package selection.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index 9c953303bb..f14f0d96dc 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -36,7 +36,8 @@ Display candidates in their existing order with stable, one-indexed numbers. A v | `--disable-interactivity`, interactivity disabled in settings or context, or `--silent` | Retain ambiguity error without reading input. | | Redirected input or output, or disabled informational output | Do not prompt. | | `--no-vt` | Use the same text and numeric input without terminal escape sequences. | -| Multi-package operations, upgrade, uninstall, repair, pin, search, list, or completion | Preserve existing behavior. | +| Multi-package operations, including individual package subcontexts | No disambiguation prompts, either per package or up front; retain existing ambiguity errors. | +| Upgrade, uninstall, repair, pin, search, list, or completion | Preserve existing behavior. | | COM API, PowerShell cmdlets, or configuration/DSC | No new prompts or API changes. | The prompt adds no settings, command-line flags, group policies, manifest fields, or schema versions. Existing interactivity settings apply. Package validation pipelines and manifest authoring tools are unchanged; manifest examples and schema snippets are not applicable. From 8aea5a2223e0075dea412c0553add78301b20f58 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:37:39 -0700 Subject: [PATCH 13/36] Allow package selection prompts with silent installation --- doc/specs/#5345 - Interactive package selection.md | 3 ++- src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 1 - src/AppInstallerCLITests/PromptFlow.cpp | 14 +++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index f14f0d96dc..37359db4f5 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -33,7 +33,8 @@ Display candidates in their existing order with stable, one-indexed numbers. A v | `0` | Cancel without acting on a package. | | Ctrl+C | Cancel immediately, including while waiting for input. | | EOF or input failure | Report the existing prompt input error. | -| `--disable-interactivity`, interactivity disabled in settings or context, or `--silent` | Retain ambiguity error without reading input. | +| `--disable-interactivity`, interactivity disabled in settings or context | Retain ambiguity error without reading input. | +| `--silent` | Controls installer UI, not selection prompts; normal interactivity rules apply. | | Redirected input or output, or disabled informational output | Do not prompt. | | `--no-vt` | Use the same text and numeric input without terminal escape sequences. | | Multi-package operations, including individual package subcontexts | No disambiguation prompts, either per package or up front; retain existing ambiguity errors. | diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 7591682d71..b73e29c296 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1404,7 +1404,6 @@ namespace AppInstaller::CLI::Workflow bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download); bool canSelect = selectionSupported && !searchResult.Truncated && - !context.Args.Contains(Execution::Args::Type::Silent) && IsInteractivityAllowed(context) && context.Reporter.CanPrompt(); if (canSelect) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index c55d30fa28..fb8a5b9856 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -228,6 +228,11 @@ TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") SECTION("Install") { + bool silent = GENERATE(false, true); + if (silent) + { + context.Args.AddArg(Execution::Args::Type::Silent); + } context.Args.AddArg(Execution::Args::Type::Force); InstallCommand({}).Execute(context); } @@ -275,9 +280,15 @@ TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") SECTION("Install") { + bool silent = GENERATE(false, true); + if (silent) + { + context.Args.AddArg(Execution::Args::Type::Silent); + } context.Args.AddArg(Execution::Args::Type::Force); context.Override({ Workflow::InstallSinglePackage, checkSelection, 1 }); InstallCommand({}).Execute(context); + REQUIRE(context.Args.Contains(Execution::Args::Type::Silent) == silent); } SECTION("Show") { @@ -549,9 +560,10 @@ TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") { settings.Set(true); } - SECTION("Silent") + SECTION("Silent with interactivity disabled") { context.Args.AddArg(Execution::Args::Type::Silent); + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); } SECTION("Redirected streams") { From 20826184f458a3de00e0a4639f3fde91655527f3 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:53:25 -0700 Subject: [PATCH 14/36] Build each package selection source row independently --- .../Workflows/WorkflowBase.cpp | 42 ++++++++++++------- src/AppInstallerCLITests/PromptFlow.cpp | 13 ++++-- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index b73e29c296..ce631d6bf4 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1418,19 +1418,18 @@ namespace AppInstaller::CLI::Workflow for (size_t i = 0; i < searchResult.Matches.size(); ++i) { auto package = searchResult.Matches[i].Package; - Execution::TableOutput<5>::line_t line{ - std::to_string(i + 1), - package->GetProperty(PackageProperty::Name), - package->GetProperty(PackageProperty::Id), - unavailable, - unavailable - }; - auto availablePackages = package->GetAvailable(); if (availablePackages.empty()) { - lines.emplace_back(std::move(line)); + lines.emplace_back(Execution::TableOutput<5>::line_t{ + std::to_string(i + 1), + package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id), + unavailable, + unavailable + }); } + bool firstSource = true; std::pair previousIdentity; for (const auto& available : availablePackages) { @@ -1438,17 +1437,28 @@ namespace AppInstaller::CLI::Workflow available->GetProperty(PackageProperty::Name), available->GetProperty(PackageProperty::Id) }; - bool repeatedIdentity = line[0].empty() && identity == previousIdentity; - line[1] = repeatedIdentity ? ""s : identity.first; - line[2] = repeatedIdentity ? ""s : identity.second; + bool repeatedIdentity = !firstSource && identity == previousIdentity; + Execution::TableOutput<5>::line_t line{ + firstSource ? std::to_string(i + 1) : ""s, + repeatedIdentity ? ""s : identity.first, + repeatedIdentity ? ""s : identity.second, + unavailable, + unavailable + }; auto version = available->GetLatestVersion(); auto source = available->GetSource(); std::string versionString = version ? version->GetProperty(PackageVersionProperty::Version).get() : std::string{}; std::string sourceName = source ? source.GetDetails().Name : std::string{}; - line[3] = versionString.empty() ? unavailable : versionString; - line[4] = sourceName.empty() ? unavailable : sourceName; - lines.emplace_back(line); - line[0].clear(); + if (!versionString.empty()) + { + line[3] = std::move(versionString); + } + if (!sourceName.empty()) + { + line[4] = std::move(sourceName); + } + lines.emplace_back(std::move(line)); + firstSource = false; previousIdentity = std::move(identity); } } diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index fb8a5b9856..a75a355cc6 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -459,6 +459,7 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); bool differentName = GENERATE(false, true); bool differentId = GENERATE(false, true); + bool missingMetadata = GENERATE(false, true); auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); manifest.Id = "Public.App"; manifest.Version = "1.0"; @@ -475,8 +476,13 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") manifest.Version = "2.0"; manifest.DefaultLocalization.Add(secondName); package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); - manifest.Version = "3.0"; - package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); + manifest.Version = std::string{ missingMetadata ? "" : "3.0" }; + auto thirdPackage = TestPackage::Make(std::vector{ manifest }, secondSource); + if (missingMetadata) + { + thirdPackage->Source.reset(); + } + package->Available.emplace_back(std::move(thirdPackage)); SearchResult result; result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, secondId }); @@ -506,11 +512,12 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") std::string line; REQUIRE(static_cast(std::getline(tableStream, line))); REQUIRE(static_cast(std::getline(tableStream, line))); + const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); std::vector> expectedRows{ { "1", "PublicName", "Public.App", "1.0", "FirstSource" }, differentName || differentId ? std::vector{ secondName, secondId, "2.0", "SecondSource" } : std::vector{ "2.0", "SecondSource" }, - { "3.0", "SecondSource" }, + { missingMetadata ? unavailable : "3.0", missingMetadata ? unavailable : "SecondSource" }, { "2", "OtherName", "Other.App", "4.0", "FirstSource" } }; for (const auto& expectedRow : expectedRows) From 42876661333f1468b09c478bf7bdefb44b3690a5 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:42:39 -0700 Subject: [PATCH 15/36] Move selection prompting into a reusable PromptFlow task --- .../AppInstallerCLICore.vcxproj | 1 + .../AppInstallerCLICore.vcxproj.filters | 3 + .../ExecutionContextData.h | 7 + src/AppInstallerCLICore/ExecutionReporter.cpp | 127 +++++------ src/AppInstallerCLICore/ExecutionReporter.h | 3 +- src/AppInstallerCLICore/TableOutput.cpp | 130 +++++++++++ src/AppInstallerCLICore/TableOutput.h | 204 ++---------------- .../Workflows/PromptFlow.cpp | 85 ++++++-- .../Workflows/PromptFlow.h | 22 +- .../Workflows/WorkflowBase.cpp | 26 +-- src/AppInstallerCLITests/PromptFlow.cpp | 148 +++++++++++-- src/AppInstallerCLITests/TableOutput.cpp | 35 +++ 12 files changed, 481 insertions(+), 310 deletions(-) create mode 100644 src/AppInstallerCLICore/TableOutput.cpp diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj index 8dd3db7bd9..f0698daaa3 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -432,6 +432,7 @@ + Create diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters index 753d2451d8..7f8606c35a 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -346,6 +346,9 @@ Source Files + + Source Files + Commands diff --git a/src/AppInstallerCLICore/ExecutionContextData.h b/src/AppInstallerCLICore/ExecutionContextData.h index 4609893dc2..103a47d3ea 100644 --- a/src/AppInstallerCLICore/ExecutionContextData.h +++ b/src/AppInstallerCLICore/ExecutionContextData.h @@ -69,6 +69,7 @@ namespace AppInstaller::CLI::Execution RepairString, MsixDigests, InstallerDownloadAuthenticators, + PromptSelection, Max }; @@ -100,6 +101,12 @@ namespace AppInstaller::CLI::Execution using value_t = Repository::SearchResult; }; + template <> + struct DataMapping + { + using value_t = std::optional; + }; + template <> struct DataMapping { diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index 74c3703eb5..d33490c7d9 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -3,7 +3,6 @@ #include "pch.h" #include "ExecutionReporter.h" #include -#include namespace AppInstaller::CLI::Execution @@ -186,97 +185,83 @@ namespace AppInstaller::CLI::Execution return m_consoleStreams && Info().IsEnabled(); } - std::optional Reporter::PromptForSelection(size_t count, std::function isCancelled) + std::optional Reporter::ReadLine(std::function isCancelled) { - THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !CanPrompt() || !count); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !CanPrompt()); + + if (isCancelled && isCancelled()) + { + return std::nullopt; + } wil::unique_handle inputThread; THROW_IF_WIN32_BOOL_FALSE(DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), inputThread.put(), THREAD_TERMINATE, FALSE, 0)); - auto out = Info(); - for (;;) + std::string response; + bool readSucceeded = false; + DWORD readError = ERROR_SUCCESS; + ProgressCallback progress; + wil::unique_event readCompleted{ wil::EventOptions::ManualReset }; + auto cancellation = progress.SetCancellationFunction([&]() { - if (isCancelled && isCancelled()) - { - return std::nullopt; - } - out << Resource::String::PackageSelectionPrompt(count) << ' ' << std::flush; - - std::string response; - bool readSucceeded = false; - DWORD readError = ERROR_SUCCESS; - ProgressCallback progress; - wil::unique_event readCompleted{ wil::EventOptions::ManualReset }; - auto cancellation = progress.SetCancellationFunction([&]() + // Retry until the read ends to cover cancellation immediately before it starts. + while (!readCompleted.wait(10)) { - // Retry until the read ends to cover cancellation immediately before it starts. - while (!readCompleted.wait(10)) + if (!CancelSynchronousIo(inputThread.get())) { - if (!CancelSynchronousIo(inputThread.get())) + DWORD error = GetLastError(); + if (error != ERROR_NOT_FOUND) { - DWORD error = GetLastError(); - if (error != ERROR_NOT_FOUND) - { - LOG_WIN32(error); - } + LOG_WIN32(error); } } + } + }); + SetProgressCallback(&progress); + { + auto unregister = wil::scope_exit([&]() + { + readCompleted.SetEvent(); + SetProgressCallback(nullptr); }); - SetProgressCallback(&progress); + if (!isCancelled || !isCancelled()) { - auto unregister = wil::scope_exit([&]() - { - readCompleted.SetEvent(); - SetProgressCallback(nullptr); - }); - if (!isCancelled || !isCancelled()) + if (m_inStreamFileType == FILE_TYPE_CHAR) { - if (m_inStreamFileType == FILE_TYPE_CHAR) - { - std::wstring consoleResponse; - do - { - wchar_t buffer[256]; - DWORD charactersRead = 0; - SetLastError(ERROR_SUCCESS); - // The CRT loses ERROR_OPERATION_ABORTED when Ctrl+C ends a console read. - bool succeeded = ReadConsoleW(GetStdHandle(STD_INPUT_HANDLE), buffer, ARRAYSIZE(buffer), &charactersRead, nullptr); - readError = GetLastError(); - readSucceeded = succeeded && charactersRead != 0; - if (!readSucceeded || readError == ERROR_OPERATION_ABORTED) - { - break; - } - consoleResponse.append(buffer, charactersRead); - } while (consoleResponse.back() != L'\n'); - response = Utility::ConvertToUTF8(consoleResponse); - readSucceeded = readSucceeded && response.find('\x1a') == std::string::npos; - } - else + std::wstring consoleResponse; + do { + wchar_t buffer[256]; + DWORD charactersRead = 0; SetLastError(ERROR_SUCCESS); - readSucceeded = static_cast(std::getline(m_in, response)); + // The CRT loses ERROR_OPERATION_ABORTED when Ctrl+C ends a console read. + bool succeeded = ReadConsoleW(GetStdHandle(STD_INPUT_HANDLE), buffer, ARRAYSIZE(buffer), &charactersRead, nullptr); readError = GetLastError(); - } + readSucceeded = succeeded && charactersRead != 0; + if (!readSucceeded || readError == ERROR_OPERATION_ABORTED) + { + break; + } + consoleResponse.append(buffer, charactersRead); + } while (consoleResponse.back() != L'\n'); + response = Utility::ConvertToUTF8(consoleResponse); + readSucceeded = readSucceeded && response.find('\x1a') == std::string::npos; + } + else + { + SetLastError(ERROR_SUCCESS); + readSucceeded = static_cast(std::getline(m_in, response)); + readError = GetLastError(); } } - if (progress.IsCancelledBy(CancelReason::Any) || (isCancelled && isCancelled()) || readError == ERROR_OPERATION_ABORTED) - { - return std::nullopt; - } - THROW_HR_IF(APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR, !readSucceeded); - Utility::Trim(response); - - size_t selection = 0; - auto result = std::from_chars(response.data(), response.data() + response.size(), selection); - if (result.ec == std::errc{} && result.ptr == response.data() + response.size() && selection <= count) - { - return selection ? std::optional{ selection - 1 } : std::nullopt; - } - - out << Resource::String::PackageSelectionInvalid(count) << std::endl; } + if (progress.IsCancelledBy(CancelReason::Any) || (isCancelled && isCancelled()) || readError == ERROR_OPERATION_ABORTED) + { + return std::nullopt; + } + THROW_HR_IF(APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR, !readSucceeded); + return response; } bool Reporter::PromptForBoolResponse(Resource::LocString message, Level level, bool resultIfDisabled) diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index 877a748cd0..6eef4c5af8 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -115,7 +115,8 @@ namespace AppInstaller::CLI::Execution bool CanPrompt(); - std::optional PromptForSelection(size_t count, std::function isCancelled = {}); + // Reads one line without output; returns nullopt on cancellation. + std::optional ReadLine(std::function isCancelled = {}); #ifndef AICLI_DISABLE_TEST_HOOKS void SetConsoleStreamsForTest(bool value) { m_consoleStreams = value; } diff --git a/src/AppInstallerCLICore/TableOutput.cpp b/src/AppInstallerCLICore/TableOutput.cpp new file mode 100644 index 0000000000..7299e789b7 --- /dev/null +++ b/src/AppInstallerCLICore/TableOutput.cpp @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TableOutput.h" + +namespace AppInstaller::CLI::Execution +{ + TableOutputBase::TableOutputBase(Reporter& reporter, std::vector header) : + m_reporter(reporter) + { + THROW_HR_IF(E_INVALIDARG, header.empty()); + for (auto& name : header) + { + auto width = Utility::UTF8ColumnWidth(name.get()); + m_columns.push_back({ std::move(name), width }); + } + } + + void TableOutputBase::OutputLine(std::vector line) + { + THROW_HR_IF(E_INVALIDARG, line.size() != m_columns.size()); + m_buffer.emplace_back(std::move(line)); + } + + void TableOutputBase::Complete() + { + if (!IsEmpty() && !m_bufferEvaluated) + { + EvaluateAndFlushBuffer(); + } + } + + void TableOutputBase::EvaluateAndFlushBuffer() + { + for (const auto& line : m_buffer) + { + for (size_t i = 0; i < m_columns.size(); ++i) + { + m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(line[i])); + } + } + + for (auto& column : m_columns) + { + if (column.MaxLength) + { + column.MaxLength = std::max(column.MaxLength, column.MinLength); + } + } + + m_columns.back().SpaceAfter = false; + for (size_t i = m_columns.size() - 1; i > 0; --i) + { + if (m_columns[i].MaxLength) + { + break; + } + m_columns[i - 1].SpaceAfter = false; + } + + size_t totalRequired = 0; + for (const auto& column : m_columns) + { + totalRequired += column.MaxLength + (column.SpaceAfter ? 1 : 0); + } + + auto consoleWidth = GetConsoleWidth(); + if (consoleWidth && totalRequired >= *consoleWidth) + { + size_t extra = (totalRequired - *consoleWidth) + 1; + while (extra) + { + auto widest = std::max_element(m_columns.begin(), m_columns.end(), + [](const auto& left, const auto& right) { return left.MaxLength < right.MaxLength; }); + --widest->MaxLength; + --extra; + } + totalRequired = *consoleWidth - 1; + } + + std::vector header; + for (const auto& column : m_columns) + { + header.emplace_back(column.Name.get()); + } + OutputLineToStream(header); + m_reporter.Info() << std::string(totalRequired, '-') << std::endl; + for (const auto& line : m_buffer) + { + OutputLineToStream(line); + } + m_bufferEvaluated = true; + } + + void TableOutputBase::OutputLineToStream(const std::vector& line) + { + auto out = m_reporter.Info(); + for (size_t i = 0; i < m_columns.size(); ++i) + { + const auto& column = m_columns[i]; + if (column.MaxLength) + { + size_t valueLength = Utility::UTF8ColumnWidth(line[i]); + if (valueLength > column.MaxLength) + { + size_t actualWidth; + out << Utility::UTF8TrimRightToColumnWidth(line[i], column.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; + // Wide characters can leave one column unused before the ellipsis. + if (actualWidth != column.MaxLength - 1) + { + out << ' '; + } + if (column.SpaceAfter) + { + out << ' '; + } + } + else + { + out << line[i]; + if (column.SpaceAfter) + { + out << std::string(column.MaxLength - valueLength + 1, ' '); + } + } + } + } + out << std::endl; + } +} diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index 6bfb59848b..c5da7d5b4f 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -5,56 +5,21 @@ #include "Resources.h" #include -#include +#include #include #include namespace AppInstaller::CLI::Execution { - // Enables output data in a table format. - // TODO: Improve for use with sparse data. - template - struct TableOutput + struct TableOutputBase { - using header_t = std::array; - using line_t = std::array; - - TableOutput(Reporter& reporter, header_t&& header) : - m_reporter(reporter), - m_hasConsole(GetConsoleWidth().has_value()) - { - for (size_t i = 0; i < FieldCount; ++i) - { - m_columns[i].Name = std::move(header[i]); - m_columns[i].MinLength = Utility::UTF8ColumnWidth(m_columns[i].Name.get()); - m_columns[i].MaxLength = 0; - } - } - - void OutputLine(line_t&& line) - { - m_empty = false; - - // Always buffer every row so that column widths are computed from the full dataset - // before any output is written. This guarantees that the widest value in any column - // is always fully visible and columns are perfectly aligned, whether output goes to - // a console or is redirected. Complete() triggers the actual output. - m_buffer.emplace_back(std::move(line)); - } + TableOutputBase(Reporter& reporter, std::vector header); - void Complete() - { - if (!m_empty) - { - EvaluateAndFlushBuffer(); - } - } - - bool IsEmpty() - { - return m_empty; - } + // Buffers rows until Complete() computes column widths and renders the table. + void OutputLine(std::vector line); + void Complete(); + bool IsEmpty() const { return m_buffer.empty(); } private: // A column in the table. @@ -67,152 +32,27 @@ namespace AppInstaller::CLI::Execution }; Reporter& m_reporter; - std::array m_columns; - std::vector m_buffer; + std::vector m_columns; + std::vector> m_buffer; bool m_bufferEvaluated = false; - bool m_empty = true; - bool m_hasConsole = false; - - void EvaluateAndFlushBuffer() - { - if (m_bufferEvaluated) - { - return; - } - - // Determine the maximum length for all columns - for (const auto& line : m_buffer) - { - for (size_t i = 0; i < FieldCount; ++i) - { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(line[i])); - } - } - - // If there are actually columns with data, then also bring in the minimum size - for (size_t i = 0; i < FieldCount; ++i) - { - if (m_columns[i].MaxLength) - { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, m_columns[i].MinLength); - } - } - - // Only output the extra space if: - // 1. Not the last field - m_columns[FieldCount - 1].SpaceAfter = false; - - // 2. Not empty (taken care of by not doing anything if empty) - // 3. There are non-empty fields after - for (size_t i = FieldCount - 1; i > 0; --i) - { - if (m_columns[i].MaxLength) - { - break; - } - else - { - m_columns[i - 1].SpaceAfter = false; - } - } - - // Determine the total width required to not truncate any columns - size_t totalRequired = 0; - for (size_t i = 0; i < FieldCount; ++i) - { - totalRequired += m_columns[i].MaxLength + (m_columns[i].SpaceAfter ? 1 : 0); - } - - auto consoleWidthOpt = GetConsoleWidth(); - - // If there is a console and the total space would be too big, shrink columns. - // We don't want to use the last column, lest we auto-wrap. - // When there is no console (e.g. output redirected to a file), skip truncation entirely. - if (consoleWidthOpt && totalRequired >= *consoleWidthOpt) - { - size_t extra = (totalRequired - *consoleWidthOpt) + 1; - - while (extra) - { - size_t targetIndex = 0; - size_t targetVal = m_columns[0].MaxLength; - for (size_t j = 1; j < FieldCount; ++j) - { - if (m_columns[j].MaxLength > targetVal) - { - targetIndex = j; - targetVal = m_columns[j].MaxLength; - } - } - m_columns[targetIndex].MaxLength -= 1; - extra -= 1; - } - - totalRequired = *consoleWidthOpt - 1; - } - - // Header line - line_t headerLine; - - for (size_t i = 0; i < FieldCount; ++i) - { - headerLine[i] = m_columns[i].Name.get(); - } - - OutputLineToStream(headerLine); - - m_reporter.Info() << std::string(totalRequired, '-') << std::endl; + void EvaluateAndFlushBuffer(); + void OutputLineToStream(const std::vector& line); + }; - for (const auto& line : m_buffer) - { - OutputLineToStream(line); - } + // Retains fixed-size headers and rows for existing table callers. + template + struct TableOutput : public TableOutputBase + { + using header_t = std::array; + using line_t = std::array; - m_bufferEvaluated = true; - } + TableOutput(Reporter& reporter, header_t&& header) : + TableOutputBase(reporter, { std::make_move_iterator(header.begin()), std::make_move_iterator(header.end()) }) {} - void OutputLineToStream(const line_t& line) + void OutputLine(line_t&& line) { - auto out = m_reporter.Info(); - - for (size_t i = 0; i < FieldCount; ++i) - { - const auto& col = m_columns[i]; - - if (col.MaxLength) - { - size_t valueLength = Utility::UTF8ColumnWidth(line[i]); - - if (valueLength > col.MaxLength) - { - size_t actualWidth; - out << Utility::UTF8TrimRightToColumnWidth(line[i], col.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; // UTF8 encoding of ellipsis (…) character - - // Some characters take 2 unit space, the trimmed string length might be 1 less than the expected length. - if (actualWidth != col.MaxLength - 1) - { - out << ' '; - } - - if (col.SpaceAfter) - { - out << ' '; - } - } - else - { - out << line[i]; - - if (col.SpaceAfter) - { - out << std::string(col.MaxLength - valueLength + 1, ' '); - } - } - } - } - - out << std::endl; + TableOutputBase::OutputLine({ std::make_move_iterator(line.begin()), std::make_move_iterator(line.end()) }); } }; } diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 068e0ddb65..8e60804c5d 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -4,6 +4,7 @@ #include "PromptFlow.h" #include "ShowFlow.h" #include +#include using namespace AppInstaller::CLI::Execution; using namespace AppInstaller::Settings; @@ -11,31 +12,31 @@ using namespace AppInstaller::Utility::literals; namespace AppInstaller::CLI::Workflow { - bool IsInteractivityAllowed(Execution::Context& context) + namespace { - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) + bool IsInteractivityAllowed(Execution::Context& context) { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); - return false; - } + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); + return false; + } - if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); - return false; - } + if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); + return false; + } - if (Settings::User().Get()) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); - return false; - } + if (Settings::User().Get()) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); + return false; + } - return true; - } + return true; + } - namespace - { bool HandleSourceAgreementsForOneSource(Execution::Context& context, const Repository::Source& source) { auto details = source.GetDetails(); @@ -390,6 +391,52 @@ namespace AppInstaller::CLI::Workflow } } + void PromptForSelection::operator()(Execution::Context& context) const + { + context.Add(std::optional{}); + AICLI_RETURN_IF_TERMINATED(context); + THROW_HR_IF(E_INVALIDARG, !m_count); + + if (!IsInteractivityAllowed(context) || !context.Reporter.CanPrompt()) + { + return; + } + + auto out = context.Reporter.Info(); + out << m_title << std::endl << std::endl; + m_table.Complete(); + out << std::endl; + + for (;;) + { + AICLI_RETURN_IF_TERMINATED(context); + out << m_prompt << ' ' << std::flush; + auto response = context.Reporter.ReadLine([&]() { return context.IsTerminated(); }); + AICLI_RETURN_IF_TERMINATED(context); + if (!response) + { + out << Resource::String::Cancelled << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); + } + + Utility::Trim(*response); + size_t selection = 0; + auto result = std::from_chars(response->data(), response->data() + response->size(), selection); + if (result.ec == std::errc{} && result.ptr == response->data() + response->size() && selection <= m_count) + { + if (!selection) + { + out << Resource::String::Cancelled << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); + } + context.Add(std::optional{ selection - 1 }); + return; + } + + out << m_invalid << std::endl; + } + } + void HandleSourceAgreements::operator()(Execution::Context& context) const { bool allAccepted = true; diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 735cfba9d4..3418a4079c 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -2,10 +2,30 @@ // Licensed under the MIT License. #pragma once #include "ExecutionContext.h" +#include "TableOutput.h" namespace AppInstaller::CLI::Workflow { - bool IsInteractivityAllowed(Execution::Context& context); + // Displays numbered choices and reads a selection when prompting is available. + // Required Args: None + // Inputs: None + // Outputs: PromptSelection (zero-based index, or nullopt if prompting is unavailable) + struct PromptForSelection : public WorkflowTask + { + PromptForSelection(Execution::TableOutputBase& table, size_t count, Resource::LocString title, + Resource::LocString prompt, Resource::LocString invalid) : + WorkflowTask("PromptForSelection"), m_table(table), m_count(count), m_title(std::move(title)), + m_prompt(std::move(prompt)), m_invalid(std::move(invalid)) {} + + void operator()(Execution::Context& context) const override; + + private: + Execution::TableOutputBase& m_table; + size_t m_count; + Resource::LocString m_title; + Resource::LocString m_prompt; + Resource::LocString m_invalid; + }; // Handles all opened source(s) agreements if needed. // Required Args: The source to be checked for agreements diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index ce631d6bf4..0a7435e20e 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1403,15 +1403,11 @@ namespace AppInstaller::CLI::Workflow bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download); - bool canSelect = selectionSupported && !searchResult.Truncated && - IsInteractivityAllowed(context) && context.Reporter.CanPrompt(); - - if (canSelect) + std::optional selection; + if (selectionSupported && !searchResult.Truncated) { - auto out = context.Reporter.Info(); auto title = m_operationType == OperationType::Install ? Resource::String::PackageSelectionInstall : m_operationType == OperationType::Download ? Resource::String::PackageSelectionDownload : Resource::String::PackageSelectionShow; - out << title << std::endl << std::endl; std::vector::line_t> lines; const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); @@ -1475,20 +1471,18 @@ namespace AppInstaller::CLI::Workflow { table.OutputLine(std::move(line)); } - table.Complete(); - - out << std::endl; - auto selection = context.Reporter.PromptForSelection(searchResult.Matches.size(), [&]() { return context.IsTerminated(); }); + context << PromptForSelection(table, searchResult.Matches.size(), title, + Resource::String::PackageSelectionPrompt(searchResult.Matches.size()), + Resource::String::PackageSelectionInvalid(searchResult.Matches.size())); AICLI_RETURN_IF_TERMINATED(context); - if (!selection) - { - context.Reporter.Info() << Resource::String::Cancelled << std::endl; - AICLI_TERMINATE_CONTEXT(E_ABORT); - } + selection = context.Get(); + } + if (selection) + { selectedIndex = *selection; auto package = searchResult.Matches[selectedIndex].Package; - out << Resource::String::PackageSelectionSelected(package->GetProperty(PackageProperty::Name), + context.Reporter.Info() << Resource::String::PackageSelectionSelected(package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id)) << std::endl; } else if (operationTargetsInstalled) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index a75a355cc6..3555e1fb26 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -20,19 +20,29 @@ using namespace AppInstaller::Settings; TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") { + TestUserSettings settings; auto response = GENERATE("1", "2", "10", " 2 \t", "0"); std::istringstream input{ response }; std::ostringstream output; - Execution::Reporter reporter{ output, input }; - reporter.SetConsoleStreamsForTest(true); + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + for (size_t i = 1; i <= 10; ++i) + { + table.OutputLine({ std::to_string(i) }); + } - auto selection = reporter.PromptForSelection(10); + context << PromptForSelection(table, 10, Resource::String::PackageSelectionInstall, + Resource::String::PackageSelectionPrompt(10), Resource::String::PackageSelectionInvalid(10)); + auto selection = context.Get(); if (std::string_view{ response } == "0") { REQUIRE_FALSE(selection); + REQUIRE_TERMINATED_WITH(context, E_ABORT); } else { + REQUIRE_FALSE(context.IsTerminated()); REQUIRE(selection == static_cast(std::stoul(response) - 1)); } REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(10).get()) != std::string::npos); @@ -40,17 +50,117 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") { + TestUserSettings settings; auto response = GENERATE("", " ", "-1", "+1", "3", "1x", "1.0", "1 2", "99999999999999999999999999"); std::istringstream input{ std::string{ response } + "\n2\n" }; std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + table.OutputLine({ "First" }); + table.OutputLine({ "Second" }); + + context << PromptForSelection(table, 2, Resource::String::PackageSelectionInstall, + Resource::String::PackageSelectionPrompt(2), Resource::String::PackageSelectionInvalid(2)); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == 1); + REQUIRE(output.str().find(Resource::String::PackageSelectionInvalid(2).get()) != std::string::npos); +} + +TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") +{ + TestUserSettings settings; + std::istringstream input{ "wrong\n2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto text = [](std::string value) + { + return Resource::LocString{ AppInstaller::Utility::LocIndString{ std::move(value) } }; + }; + Execution::TableOutput<1> table(context.Reporter, { text("Choice") }); + table.OutputLine({ "1 First" }); + table.OutputLine({ "2 Second" }); + context << PromptForSelection(table, 2, text("Choose a value"), text("Number:"), text("Try again")); + + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == 1); + REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\nNumber: Try again\nNumber: "); +} + +TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") +{ + TestUserSettings settings; + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Add(std::optional{0}); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + table.OutputLine({ "First" }); + + SECTION("Context disabled") + { + context.SetFlags(Execution::ContextFlag::DisableInteractivity); + } + SECTION("Argument disabled") + { + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + SECTION("Setting disabled") + { + settings.Set(true); + } + SECTION("Redirected streams") + { + context.Reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden output") + { + context.Reporter.SetLevelMask(Execution::Reporter::Level::Info, false); + } + + context << PromptForSelection(table, 1, Resource::String::PackageSelectionInstall, + Resource::String::PackageSelectionPrompt(1), Resource::String::PackageSelectionInvalid(1)); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE_FALSE(context.Get()); + REQUIRE(output.str().empty()); + REQUIRE(input.peek() == '1'); +} + +TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") +{ + TestUserSettings settings; + std::istringstream input; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + table.OutputLine({ "First" }); + auto count = GENERATE(size_t{0}, size_t{1}); + PromptForSelection prompt(table, count, Resource::String::PackageSelectionInstall, + Resource::String::PackageSelectionPrompt(count), Resource::String::PackageSelectionInvalid(count)); + + REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); + REQUIRE_FALSE(context.Get()); +} + +TEST_CASE("ReporterReadLine", "[PromptFlow]") +{ + auto response = GENERATE("", " text \t", "0", "invalid", "99999999999999999999999999"); + std::istringstream input{ std::string{ response } + "\nnext\n" }; + std::ostringstream output; Execution::Reporter reporter{ output, input }; reporter.SetConsoleStreamsForTest(true); - REQUIRE(reporter.PromptForSelection(2) == 1); - REQUIRE(output.str().find(Resource::String::PackageSelectionInvalid(2).get()) != std::string::npos); + REQUIRE(reporter.ReadLine() == response); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str().empty()); } -TEST_CASE("PackageSelection_InputFailure", "[PackageSelection][PromptFlow]") +TEST_CASE("ReporterReadLine_InputFailure", "[PromptFlow]") { std::istringstream input; std::ostringstream output; @@ -59,12 +169,12 @@ TEST_CASE("PackageSelection_InputFailure", "[PackageSelection][PromptFlow]") SECTION("EOF") { - REQUIRE_THROWS_HR(reporter.PromptForSelection(2), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); + REQUIRE_THROWS_HR(reporter.ReadLine(), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); } SECTION("Cancelled input failure") { int checks = 0; - REQUIRE_FALSE(reporter.PromptForSelection(2, [&]() { return ++checks == 3; })); + REQUIRE_FALSE(reporter.ReadLine([&]() { return ++checks == 3; })); } SECTION("Console read aborts before the signal handler runs") { @@ -79,13 +189,14 @@ TEST_CASE("PackageSelection_InputFailure", "[PackageSelection][PromptFlow]") std::istream abortedInput{ &buffer }; Execution::Reporter abortedReporter{ output, abortedInput }; abortedReporter.SetConsoleStreamsForTest(true); - REQUIRE_FALSE(abortedReporter.PromptForSelection(2, []() { return false; })); + REQUIRE_FALSE(abortedReporter.ReadLine([]() { return false; })); } + REQUIRE(output.str().empty()); } -TEST_CASE("PackageSelection_CancelBeforeRead", "[PackageSelection][PromptFlow]") +TEST_CASE("ReporterReadLine_CancelBeforeRead", "[PromptFlow]") { - auto cancelOnCheck = GENERATE(1, 2, 3, 4); + auto cancelOnCheck = GENERATE(1, 2, 3); std::istringstream input{ "invalid\n2\n" }; std::ostringstream output; TestContext context{ output, input }; @@ -100,16 +211,13 @@ TEST_CASE("PackageSelection_CancelBeforeRead", "[PackageSelection][PromptFlow]") return context.IsTerminated(); }; - REQUIRE_FALSE(context.Reporter.PromptForSelection(2, isCancelled)); + REQUIRE_FALSE(context.Reporter.ReadLine(isCancelled)); REQUIRE_TERMINATED_WITH(context, E_ABORT); REQUIRE(input.peek() == (cancelOnCheck >= 3 ? '2' : 'i')); - if (cancelOnCheck == 1) - { - REQUIRE(output.str().empty()); - } + REQUIRE(output.str().empty()); } -TEST_CASE("PackageSelection_CancelPendingRead", "[PackageSelection][PromptFlow]") +TEST_CASE("ReporterReadLine_CancelPendingRead", "[PromptFlow]") { struct PipeInputBuffer : std::streambuf { @@ -166,14 +274,14 @@ TEST_CASE("PackageSelection_CancelPendingRead", "[PackageSelection][PromptFlow]" watchdog.join(); }); - REQUIRE_FALSE(context.Reporter.PromptForSelection(2, [&]() { return context.IsTerminated(); })); + REQUIRE_FALSE(context.Reporter.ReadLine([&]() { return context.IsTerminated(); })); finished.SetEvent(); cancel.join(); watchdog.join(); join.release(); REQUIRE_FALSE(timedOut); REQUIRE_TERMINATED_WITH(context, E_ABORT); - REQUIRE(output.str().find(Resource::String::PackageSelectionInvalid(2).get()) == std::string::npos); + REQUIRE(output.str().empty()); } TEST_CASE("PackageSelection_ConsoleStreams", "[PackageSelection][PromptFlow]") @@ -211,7 +319,7 @@ TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow } REQUIRE_FALSE(reporter.CanPrompt()); - REQUIRE_THROWS_HR(reporter.PromptForSelection(2), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); + REQUIRE_THROWS_HR(reporter.ReadLine(), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); REQUIRE(input.peek() == '1'); } diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index 2d5480083f..3fcdcc52c3 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -19,6 +19,41 @@ namespace } } +TEST_CASE("TableOutput_DynamicMatchesTyped", "[tableoutput]") +{ + std::ostringstream typedOutput; + std::ostringstream dynamicOutput; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{GENERATE(size_t{20}, size_t{120})} }; + Reporter typedReporter(typedOutput, input); + Reporter dynamicReporter(dynamicOutput, input); + typedReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + dynamicReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + TableOutput<3> typed(typedReporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Id") }); + TableOutputBase dynamic(dynamicReporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Id") }); + typed.OutputLine({ "LongPackageName", "", "test.id" }); + dynamic.OutputLine({ "LongPackageName", "", "test.id" }); + typed.Complete(); + dynamic.Complete(); + + REQUIRE_FALSE(dynamic.IsEmpty()); + REQUIRE(dynamicOutput.str() == typedOutput.str()); + dynamic.Complete(); + REQUIRE(dynamicOutput.str() == typedOutput.str()); +} + +TEST_CASE("TableOutput_DynamicInvalidDimensions", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + Reporter reporter(output, input); + REQUIRE_THROWS_HR(TableOutputBase(reporter, {}), E_INVALIDARG); + TableOutputBase table(reporter, { MakeHeader("Name") }); + REQUIRE_THROWS_HR(table.OutputLine({ "Name", "Extra" }), E_INVALIDARG); + REQUIRE(table.IsEmpty()); + REQUIRE(output.str().empty()); +} + // Test that all rows are buffered and column widths account for values beyond the first 50 rows. // In the old sizing-buffer design, a row at position 55 with a longer value than any of the // first 50 rows would be truncated. The new design buffers every row so no value is clipped. From b85ba3bed5eab91b7a2ef9a3038dc7292172036e Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:41:18 -0700 Subject: [PATCH 16/36] Fix spelling check failures in selection spec and tests --- doc/specs/#5345 - Interactive package selection.md | 2 +- src/AppInstallerCLITests/PromptFlow.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index 37359db4f5..e3df602413 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -37,7 +37,7 @@ Display candidates in their existing order with stable, one-indexed numbers. A v | `--silent` | Controls installer UI, not selection prompts; normal interactivity rules apply. | | Redirected input or output, or disabled informational output | Do not prompt. | | `--no-vt` | Use the same text and numeric input without terminal escape sequences. | -| Multi-package operations, including individual package subcontexts | No disambiguation prompts, either per package or up front; retain existing ambiguity errors. | +| Multi-package operations, including individual package contexts | No disambiguation prompts, either per package or up front; retain existing ambiguity errors. | | Upgrade, uninstall, repair, pin, search, list, or completion | Preserve existing behavior. | | COM API, PowerShell cmdlets, or configuration/DSC | No new prompts or API changes. | diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 3555e1fb26..2c949b486e 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -150,7 +150,7 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") TEST_CASE("ReporterReadLine", "[PromptFlow]") { auto response = GENERATE("", " text \t", "0", "invalid", "99999999999999999999999999"); - std::istringstream input{ std::string{ response } + "\nnext\n" }; + std::istringstream input{ std::string{ response } + '\n' + "next\n" }; std::ostringstream output; Execution::Reporter reporter{ output, input }; reporter.SetConsoleStreamsForTest(true); From 34614f678f0add7d32e0265696b581122e7e95e6 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:07:58 -0700 Subject: [PATCH 17/36] Remove redundant guidance from invalid selection feedback --- src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 2 +- .../Shared/Strings/en-us/winget.resw | 4 ++-- src/AppInstallerCLITests/PromptFlow.cpp | 12 +++++++----- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 0a7435e20e..c9136cc605 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1473,7 +1473,7 @@ namespace AppInstaller::CLI::Workflow } context << PromptForSelection(table, searchResult.Matches.size(), title, Resource::String::PackageSelectionPrompt(searchResult.Matches.size()), - Resource::String::PackageSelectionInvalid(searchResult.Matches.size())); + Resource::String::PackageSelectionInvalid); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); } diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index d96d0e19e4..22275a7a6a 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -424,8 +424,8 @@ They can be configured through the settings file 'winget settings'. {Locked="{0}"} {0} is the number of packages in the list. The input is a numeric choice; 0 cancels the command. - Invalid selection. Enter a number from 1 to {0}, or 0 to cancel. - {Locked="{0}"} {0} is the number of packages in the list. Shown after invalid or empty input. + Invalid selection. + Shown after invalid or empty input. The selection prompt repeats the valid range and cancellation option. Selected: {0} [{1}] diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 2c949b486e..f921ff4cbe 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -33,7 +33,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") } context << PromptForSelection(table, 10, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(10), Resource::String::PackageSelectionInvalid(10)); + Resource::String::PackageSelectionPrompt(10), Resource::String::PackageSelectionInvalid); auto selection = context.Get(); if (std::string_view{ response } == "0") { @@ -61,10 +61,12 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") table.OutputLine({ "Second" }); context << PromptForSelection(table, 2, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(2), Resource::String::PackageSelectionInvalid(2)); + Resource::String::PackageSelectionPrompt(2), Resource::String::PackageSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == 1); - REQUIRE(output.str().find(Resource::String::PackageSelectionInvalid(2).get()) != std::string::npos); + const std::string prompt = Resource::String::PackageSelectionPrompt(2).get(); + const std::string invalid = Resource::LocString{ Resource::String::PackageSelectionInvalid }.get(); + REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); } TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") @@ -123,7 +125,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") } context << PromptForSelection(table, 1, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(1), Resource::String::PackageSelectionInvalid(1)); + Resource::String::PackageSelectionPrompt(1), Resource::String::PackageSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE_FALSE(context.Get()); REQUIRE(output.str().empty()); @@ -141,7 +143,7 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") table.OutputLine({ "First" }); auto count = GENERATE(size_t{0}, size_t{1}); PromptForSelection prompt(table, count, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(count), Resource::String::PackageSelectionInvalid(count)); + Resource::String::PackageSelectionPrompt(count), Resource::String::PackageSelectionInvalid); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); REQUIRE_FALSE(context.Get()); From b6042c510526901c1f4e25c99bf6b2361b91c8e9 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:25:11 -0700 Subject: [PATCH 18/36] Move numbered selection prompt ownership into PromptFlow --- src/AppInstallerCLICore/Resources.h | 2 +- .../Workflows/PromptFlow.cpp | 3 +- .../Workflows/PromptFlow.h | 5 ++-- .../Workflows/WorkflowBase.cpp | 1 - .../Shared/Strings/en-us/winget.resw | 4 +-- src/AppInstallerCLITests/PromptFlow.cpp | 28 ++++++++++--------- 6 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index d6c9f6a27b..cc85ed9f1a 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -515,6 +515,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(NoUninstallInfoFound); WINGET_DEFINE_RESOURCE_STRINGID(NoUpgradeArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(NoVTArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(NumberedSelectionPrompt); WINGET_DEFINE_RESOURCE_STRINGID(OpenLogsArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatch); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatchHelp); @@ -535,7 +536,6 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionDownload); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInstall); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInvalid); - WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionPrompt); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefine); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionSelected); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionShow); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 8e60804c5d..a1c9d9ec48 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -407,10 +407,11 @@ namespace AppInstaller::CLI::Workflow m_table.Complete(); out << std::endl; + const auto prompt = Resource::String::NumberedSelectionPrompt(m_count); for (;;) { AICLI_RETURN_IF_TERMINATED(context); - out << m_prompt << ' ' << std::flush; + out << prompt << ' ' << std::flush; auto response = context.Reporter.ReadLine([&]() { return context.IsTerminated(); }); AICLI_RETURN_IF_TERMINATED(context); if (!response) diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 3418a4079c..408c9d62ce 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -13,9 +13,9 @@ namespace AppInstaller::CLI::Workflow struct PromptForSelection : public WorkflowTask { PromptForSelection(Execution::TableOutputBase& table, size_t count, Resource::LocString title, - Resource::LocString prompt, Resource::LocString invalid) : + Resource::LocString invalid) : WorkflowTask("PromptForSelection"), m_table(table), m_count(count), m_title(std::move(title)), - m_prompt(std::move(prompt)), m_invalid(std::move(invalid)) {} + m_invalid(std::move(invalid)) {} void operator()(Execution::Context& context) const override; @@ -23,7 +23,6 @@ namespace AppInstaller::CLI::Workflow Execution::TableOutputBase& m_table; size_t m_count; Resource::LocString m_title; - Resource::LocString m_prompt; Resource::LocString m_invalid; }; diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index c9136cc605..2a1fcfbfd4 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1472,7 +1472,6 @@ namespace AppInstaller::CLI::Workflow table.OutputLine(std::move(line)); } context << PromptForSelection(table, searchResult.Matches.size(), title, - Resource::String::PackageSelectionPrompt(searchResult.Matches.size()), Resource::String::PackageSelectionInvalid); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 22275a7a6a..fbe5f193c3 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -419,9 +419,9 @@ They can be configured through the settings file 'winget settings'. Multiple packages match. Choose one to view. Introduces a numbered list of packages whose information can be displayed. - + Enter a number (1-{0}), or 0 to cancel: - {Locked="{0}"} {0} is the number of packages in the list. The input is a numeric choice; 0 cancels the command. + {Locked="{0}"} {0} is the number of choices in the list. The input is a numeric choice; 0 cancels the command. Invalid selection. diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index f921ff4cbe..a2a11df861 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -33,7 +33,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") } context << PromptForSelection(table, 10, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(10), Resource::String::PackageSelectionInvalid); + Resource::String::PackageSelectionInvalid); auto selection = context.Get(); if (std::string_view{ response } == "0") { @@ -45,7 +45,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") REQUIRE_FALSE(context.IsTerminated()); REQUIRE(selection == static_cast(std::stoul(response) - 1)); } - REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(10).get()) != std::string::npos); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(10).get()) != std::string::npos); } TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") @@ -61,10 +61,10 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") table.OutputLine({ "Second" }); context << PromptForSelection(table, 2, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(2), Resource::String::PackageSelectionInvalid); + Resource::String::PackageSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == 1); - const std::string prompt = Resource::String::PackageSelectionPrompt(2).get(); + const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); const std::string invalid = Resource::LocString{ Resource::String::PackageSelectionInvalid }.get(); REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); } @@ -85,11 +85,13 @@ TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") Execution::TableOutput<1> table(context.Reporter, { text("Choice") }); table.OutputLine({ "1 First" }); table.OutputLine({ "2 Second" }); - context << PromptForSelection(table, 2, text("Choose a value"), text("Number:"), text("Try again")); + context << PromptForSelection(table, 2, text("Choose a value"), text("Try again")); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == 1); - REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\nNumber: Try again\nNumber: "); + const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); + REQUIRE_FALSE(prompt.empty()); + REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\n" + prompt + " Try again\n" + prompt + " "); } TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") @@ -125,7 +127,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") } context << PromptForSelection(table, 1, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(1), Resource::String::PackageSelectionInvalid); + Resource::String::PackageSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE_FALSE(context.Get()); REQUIRE(output.str().empty()); @@ -143,7 +145,7 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") table.OutputLine({ "First" }); auto count = GENERATE(size_t{0}, size_t{1}); PromptForSelection prompt(table, count, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionPrompt(count), Resource::String::PackageSelectionInvalid); + Resource::String::PackageSelectionInvalid); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); REQUIRE_FALSE(context.Get()); @@ -364,7 +366,7 @@ TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") REQUIRE_TERMINATED_WITH(context, E_ABORT); REQUIRE_FALSE(context.Contains(Execution::Data::Package)); REQUIRE_FALSE(context.Contains(Execution::Data::Manifest)); - REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos); } TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") @@ -413,7 +415,7 @@ TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") INFO(output.str()); REQUIRE_TERMINATED_WITH(context, E_ABORT); - REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos); } TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][MultiQuery]") @@ -438,7 +440,7 @@ TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][Mult INFO(output.str()); REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_NOT_ALL_QUERIES_FOUND_SINGLE); REQUIRE(input.peek() == '1'); - REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); } TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][workflow]") @@ -511,7 +513,7 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w INFO(output.str()); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == expectedPackage); - REQUIRE((output.str().find(Resource::String::PackageSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); + REQUIRE((output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); if (expectPrompt) { auto tableStart = output.str().find("\n# "); @@ -711,7 +713,7 @@ TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") REQUIRE_TERMINATED_WITH(context, expectedError); REQUIRE_FALSE(context.Contains(Execution::Data::Package)); REQUIRE(input.peek() == '2'); - REQUIRE(output.str().find(Resource::String::PackageSelectionPrompt(2).get()) == std::string::npos); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); } TEST_CASE("PromptFlow_InteractivityDisabled", "[PromptFlow][workflow]") From 643ef46704683466647a2415e32610b8d5b87092 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:54:10 -0700 Subject: [PATCH 19/36] Derive the selection count from numbered table rows --- src/AppInstallerCLICore/TableOutput.cpp | 9 ++++++ src/AppInstallerCLICore/TableOutput.h | 1 + .../Workflows/PromptFlow.cpp | 7 +++-- .../Workflows/PromptFlow.h | 9 ++---- .../Workflows/WorkflowBase.cpp | 3 +- src/AppInstallerCLITests/PromptFlow.cpp | 29 +++++++++++++------ src/AppInstallerCLITests/TableOutput.cpp | 24 +++++++++++++++ 7 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/AppInstallerCLICore/TableOutput.cpp b/src/AppInstallerCLICore/TableOutput.cpp index 7299e789b7..b92f6644dc 100644 --- a/src/AppInstallerCLICore/TableOutput.cpp +++ b/src/AppInstallerCLICore/TableOutput.cpp @@ -30,6 +30,15 @@ namespace AppInstaller::CLI::Execution } } + size_t TableOutputBase::GetNonEmptyRowCount(size_t column) const + { + THROW_HR_IF(E_INVALIDARG, column >= m_columns.size()); + return static_cast(std::count_if(m_buffer.begin(), m_buffer.end(), [column](const auto& line) + { + return !line[column].empty(); + })); + } + void TableOutputBase::EvaluateAndFlushBuffer() { for (const auto& line : m_buffer) diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index c5da7d5b4f..f61afbab4a 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -20,6 +20,7 @@ namespace AppInstaller::CLI::Execution void OutputLine(std::vector line); void Complete(); bool IsEmpty() const { return m_buffer.empty(); } + size_t GetNonEmptyRowCount(size_t column) const; private: // A column in the table. diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index a1c9d9ec48..aa37b46903 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -395,7 +395,8 @@ namespace AppInstaller::CLI::Workflow { context.Add(std::optional{}); AICLI_RETURN_IF_TERMINATED(context); - THROW_HR_IF(E_INVALIDARG, !m_count); + const size_t count = m_table.GetNonEmptyRowCount(0); + THROW_HR_IF(E_INVALIDARG, !count); if (!IsInteractivityAllowed(context) || !context.Reporter.CanPrompt()) { @@ -407,7 +408,7 @@ namespace AppInstaller::CLI::Workflow m_table.Complete(); out << std::endl; - const auto prompt = Resource::String::NumberedSelectionPrompt(m_count); + const auto prompt = Resource::String::NumberedSelectionPrompt(count); for (;;) { AICLI_RETURN_IF_TERMINATED(context); @@ -423,7 +424,7 @@ namespace AppInstaller::CLI::Workflow Utility::Trim(*response); size_t selection = 0; auto result = std::from_chars(response->data(), response->data() + response->size(), selection); - if (result.ec == std::errc{} && result.ptr == response->data() + response->size() && selection <= m_count) + if (result.ec == std::errc{} && result.ptr == response->data() + response->size() && selection <= count) { if (!selection) { diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 408c9d62ce..6f5ac6ffc7 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -6,22 +6,19 @@ namespace AppInstaller::CLI::Workflow { - // Displays numbered choices and reads a selection when prompting is available. + // Prompts for a numbered choice; rows with an empty first column are continuations. // Required Args: None // Inputs: None // Outputs: PromptSelection (zero-based index, or nullopt if prompting is unavailable) struct PromptForSelection : public WorkflowTask { - PromptForSelection(Execution::TableOutputBase& table, size_t count, Resource::LocString title, - Resource::LocString invalid) : - WorkflowTask("PromptForSelection"), m_table(table), m_count(count), m_title(std::move(title)), - m_invalid(std::move(invalid)) {} + PromptForSelection(Execution::TableOutputBase& table, Resource::LocString title, Resource::LocString invalid) : + WorkflowTask("PromptForSelection"), m_table(table), m_title(std::move(title)), m_invalid(std::move(invalid)) {} void operator()(Execution::Context& context) const override; private: Execution::TableOutputBase& m_table; - size_t m_count; Resource::LocString m_title; Resource::LocString m_invalid; }; diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 2a1fcfbfd4..4beb61847f 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1471,8 +1471,7 @@ namespace AppInstaller::CLI::Workflow { table.OutputLine(std::move(line)); } - context << PromptForSelection(table, searchResult.Matches.size(), title, - Resource::String::PackageSelectionInvalid); + context << PromptForSelection(table, title, Resource::String::PackageSelectionInvalid); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); } diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index a2a11df861..d3b7e6a213 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -32,7 +32,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") table.OutputLine({ std::to_string(i) }); } - context << PromptForSelection(table, 10, Resource::String::PackageSelectionInstall, + context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::PackageSelectionInvalid); auto selection = context.Get(); if (std::string_view{ response } == "0") @@ -56,11 +56,15 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") std::ostringstream output; TestContext context{ output, input }; context.Reporter.SetConsoleStreamsForTest(true); - Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); - table.OutputLine({ "First" }); - table.OutputLine({ "Second" }); + Execution::TableOutput<2> table(context.Reporter, { Resource::String::SearchName, Resource::String::SearchSource }); + table.OutputLine({ "First", "FirstSource" }); + if (GENERATE(false, true)) + { + table.OutputLine({ "", "SecondSource" }); + } + table.OutputLine({ "Second", "FirstSource" }); - context << PromptForSelection(table, 2, Resource::String::PackageSelectionInstall, + context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::PackageSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == 1); @@ -85,7 +89,7 @@ TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") Execution::TableOutput<1> table(context.Reporter, { text("Choice") }); table.OutputLine({ "1 First" }); table.OutputLine({ "2 Second" }); - context << PromptForSelection(table, 2, text("Choose a value"), text("Try again")); + context << PromptForSelection(table, text("Choose a value"), text("Try again")); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == 1); @@ -126,7 +130,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") context.Reporter.SetLevelMask(Execution::Reporter::Level::Info, false); } - context << PromptForSelection(table, 1, Resource::String::PackageSelectionInstall, + context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::PackageSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE_FALSE(context.Get()); @@ -142,9 +146,16 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") TestContext context{ output, input }; context.Reporter.SetConsoleStreamsForTest(true); Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); - table.OutputLine({ "First" }); auto count = GENERATE(size_t{0}, size_t{1}); - PromptForSelection prompt(table, count, Resource::String::PackageSelectionInstall, + if (count) + { + table.OutputLine({ "First" }); + } + if (GENERATE(false, true)) + { + table.OutputLine({ "" }); + } + PromptForSelection prompt(table, Resource::String::PackageSelectionInstall, Resource::String::PackageSelectionInvalid); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index 3fcdcc52c3..6d6b9f980f 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -54,6 +54,30 @@ TEST_CASE("TableOutput_DynamicInvalidDimensions", "[tableoutput]") REQUIRE(output.str().empty()); } +TEST_CASE("TableOutput_NonEmptyRowCount", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + Reporter reporter(output, input); + TableOutput<2> table(reporter, { MakeHeader("Choice"), MakeHeader("Source") }); + REQUIRE(table.GetNonEmptyRowCount(0) == size_t{0}); + REQUIRE(table.GetNonEmptyRowCount(1) == size_t{0}); + REQUIRE_THROWS_HR(table.GetNonEmptyRowCount(2), E_INVALIDARG); + + table.OutputLine({ "1", "FirstSource" }); + table.OutputLine({ "", "SecondSource" }); + table.OutputLine({ "2", "ThirdSource" }); + table.OutputLine({ "", "" }); + REQUIRE(table.GetNonEmptyRowCount(0) == size_t{2}); + REQUIRE(table.GetNonEmptyRowCount(1) == size_t{3}); + REQUIRE(output.str().empty()); + + table.Complete(); + REQUIRE(table.GetNonEmptyRowCount(0) == size_t{2}); + REQUIRE(table.GetNonEmptyRowCount(1) == size_t{3}); +} + // Test that all rows are buffered and column widths account for values beyond the first 50 rows. // In the old sizing-buffer design, a row at position 55 with a longer value than any of the // first 50 rows would be truncated. The new design buffers every row so no value is clipped. From 9da01fa241e7ce01ef676bc36b30040611ca946f Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:26:52 -0700 Subject: [PATCH 20/36] Add typed integer prompting for numbered selection --- src/AppInstallerCLICore/ExecutionReporter.cpp | 43 ++++++- src/AppInstallerCLICore/ExecutionReporter.h | 7 +- src/AppInstallerCLICore/Resources.h | 2 +- .../Workflows/PromptFlow.cpp | 13 +-- .../Workflows/WorkflowBase.cpp | 2 +- .../Shared/Strings/en-us/winget.resw | 4 +- src/AppInstallerCLITests/PromptFlow.cpp | 106 ++++++++++++++++-- 7 files changed, 149 insertions(+), 28 deletions(-) diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index d33490c7d9..2c709aecb5 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "ExecutionReporter.h" #include +#include namespace AppInstaller::CLI::Execution @@ -180,14 +181,14 @@ namespace AppInstaller::CLI::Execution return m_inStreamFileType == FILE_TYPE_CHAR; } - bool Reporter::CanPrompt() + bool Reporter::CanPrompt(Level level) { - return m_consoleStreams && Info().IsEnabled(); + return m_consoleStreams && GetOutputStream(level).IsEnabled(); } std::optional Reporter::ReadLine(std::function isCancelled) { - THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !CanPrompt()); + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_consoleStreams); if (isCancelled && isCancelled()) { @@ -264,6 +265,42 @@ namespace AppInstaller::CLI::Execution return response; } + std::optional Reporter::PromptForIntegerResponse(Resource::LocString message, Level level, + Resource::LocString invalid, std::function isCancelled) + { + if (!CanPrompt(level)) + { + AICLI_LOG(CLI, Verbose, << "Skipping integer prompt. Console streams or output are unavailable."); + return std::nullopt; + } + + auto out = GetOutputStream(level); + for (;;) + { + if (isCancelled && isCancelled()) + { + return std::nullopt; + } + + out << message << ' ' << std::flush; + auto response = ReadLine(isCancelled); + if (!response || (isCancelled && isCancelled())) + { + return std::nullopt; + } + + Utility::Trim(*response); + uint64_t value = 0; + auto result = std::from_chars(response->data(), response->data() + response->size(), value); + if (result.ec == std::errc{} && result.ptr == response->data() + response->size()) + { + return value; + } + + out << invalid << std::endl; + } + } + bool Reporter::PromptForBoolResponse(Resource::LocString message, Level level, bool resultIfDisabled) { auto out = GetOutputStream(level); diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index 6eef4c5af8..e2dfe8becb 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -113,7 +114,7 @@ namespace AppInstaller::CLI::Execution // Check if the input stream is interactive or not. bool InputStreamIsInteractive() const; - bool CanPrompt(); + bool CanPrompt(Level level = Level::Info); // Reads one line without output; returns nullopt on cancellation. std::optional ReadLine(std::function isCancelled = {}); @@ -122,6 +123,10 @@ namespace AppInstaller::CLI::Execution void SetConsoleStreamsForTest(bool value) { m_consoleStreams = value; } #endif + // Prompts for a non-negative integer; returns nullopt if unavailable or cancelled. + std::optional PromptForIntegerResponse(Resource::LocString message, Level level = Level::Info, + Resource::LocString invalid = Resource::String::NumberedSelectionInvalid, std::function isCancelled = {}); + // Prompts the user, return true if they consented. bool PromptForBoolResponse(Resource::LocString message, Level level = Level::Info, bool resultIfDisabled = false); diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index cc85ed9f1a..5f6434828e 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -515,6 +515,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(NoUninstallInfoFound); WINGET_DEFINE_RESOURCE_STRINGID(NoUpgradeArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(NoVTArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(NumberedSelectionInvalid); WINGET_DEFINE_RESOURCE_STRINGID(NumberedSelectionPrompt); WINGET_DEFINE_RESOURCE_STRINGID(OpenLogsArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatch); @@ -535,7 +536,6 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PackageRequiresDependencies); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionDownload); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInstall); - WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInvalid); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefine); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionSelected); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionShow); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index aa37b46903..3d4f3b1e60 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -4,7 +4,6 @@ #include "PromptFlow.h" #include "ShowFlow.h" #include -#include using namespace AppInstaller::CLI::Execution; using namespace AppInstaller::Settings; @@ -412,8 +411,8 @@ namespace AppInstaller::CLI::Workflow for (;;) { AICLI_RETURN_IF_TERMINATED(context); - out << prompt << ' ' << std::flush; - auto response = context.Reporter.ReadLine([&]() { return context.IsTerminated(); }); + auto response = context.Reporter.PromptForIntegerResponse(prompt, Reporter::Level::Info, m_invalid, + [&]() { return context.IsTerminated(); }); AICLI_RETURN_IF_TERMINATED(context); if (!response) { @@ -421,17 +420,15 @@ namespace AppInstaller::CLI::Workflow AICLI_TERMINATE_CONTEXT(E_ABORT); } - Utility::Trim(*response); - size_t selection = 0; - auto result = std::from_chars(response->data(), response->data() + response->size(), selection); - if (result.ec == std::errc{} && result.ptr == response->data() + response->size() && selection <= count) + uint64_t selection = *response; + if (selection <= count) { if (!selection) { out << Resource::String::Cancelled << std::endl; AICLI_TERMINATE_CONTEXT(E_ABORT); } - context.Add(std::optional{ selection - 1 }); + context.Add(std::optional{ static_cast(selection - 1) }); return; } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 4beb61847f..9d45fdea9f 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1471,7 +1471,7 @@ namespace AppInstaller::CLI::Workflow { table.OutputLine(std::move(line)); } - context << PromptForSelection(table, title, Resource::String::PackageSelectionInvalid); + context << PromptForSelection(table, title, Resource::String::NumberedSelectionInvalid); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); } diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index fbe5f193c3..fe2b5f8ffe 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -423,9 +423,9 @@ They can be configured through the settings file 'winget settings'. Enter a number (1-{0}), or 0 to cancel: {Locked="{0}"} {0} is the number of choices in the list. The input is a numeric choice; 0 cancels the command. - + Invalid selection. - Shown after invalid or empty input. The selection prompt repeats the valid range and cancellation option. + Shown after invalid or empty input to a numbered selection prompt. Selected: {0} [{1}] diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index d3b7e6a213..5e2c586629 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -33,7 +33,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") } context << PromptForSelection(table, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionInvalid); + Resource::String::NumberedSelectionInvalid); auto selection = context.Get(); if (std::string_view{ response } == "0") { @@ -51,7 +51,8 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") { TestUserSettings settings; - auto response = GENERATE("", " ", "-1", "+1", "3", "1x", "1.0", "1 2", "99999999999999999999999999"); + auto response = GENERATE("", " ", "-1", "-0", "+1", "3", "1x", "1.0", "1 2", + "4294967297", "18446744073709551615", "99999999999999999999999999"); std::istringstream input{ std::string{ response } + "\n2\n" }; std::ostringstream output; TestContext context{ output, input }; @@ -65,11 +66,11 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") table.OutputLine({ "Second", "FirstSource" }); context << PromptForSelection(table, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionInvalid); + Resource::String::NumberedSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == 1); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); - const std::string invalid = Resource::LocString{ Resource::String::PackageSelectionInvalid }.get(); + const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); } @@ -131,7 +132,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") } context << PromptForSelection(table, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionInvalid); + Resource::String::NumberedSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); REQUIRE_FALSE(context.Get()); REQUIRE(output.str().empty()); @@ -156,12 +157,74 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") table.OutputLine({ "" }); } PromptForSelection prompt(table, Resource::String::PackageSelectionInstall, - Resource::String::PackageSelectionInvalid); + Resource::String::NumberedSelectionInvalid); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); REQUIRE_FALSE(context.Get()); } +TEST_CASE("ReporterPromptForIntegerResponse", "[PromptFlow]") +{ + auto response = GENERATE("0", "1", "42", " 42 \t", "18446744073709551615"); + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); + std::istringstream input{ std::string{ response } + '\n' + "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + reporter.SetLevelMask(Execution::Reporter::Level::All, false); + reporter.SetLevelMask(level); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + auto result = reporter.PromptForIntegerResponse(message, level); + REQUIRE(result.has_value()); + REQUIRE(*result == std::stoull(response)); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str() == "Number: "); +} + +TEST_CASE("ReporterPromptForIntegerResponse_InvalidInput", "[PromptFlow]") +{ + auto response = GENERATE("", " ", "-1", "-0", "+1", "1x", "1.0", "1 2", "18446744073709551616"); + std::istringstream input{ std::string{ response } + '\n' + "2\n" + "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + REQUIRE(reporter.PromptForIntegerResponse(message) == uint64_t{2}); + REQUIRE(input.peek() == 'n'); + const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); + REQUIRE_FALSE(invalid.empty()); + REQUIRE(output.str() == "Number: " + invalid + '\n' + "Number: "); +} + +TEST_CASE("ReporterPromptForIntegerResponse_InputFailure", "[PromptFlow]") +{ + std::istringstream input; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + std::string expectedOutput = "Number: "; + + SECTION("EOF") + { + REQUIRE_THROWS_HR(reporter.PromptForIntegerResponse(message), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); + } + SECTION("Cancelled before prompting") + { + input.str("1\n"); + REQUIRE_FALSE(reporter.PromptForIntegerResponse(message, Execution::Reporter::Level::Info, + Resource::String::NumberedSelectionInvalid, []() { return true; })); + REQUIRE(input.peek() == '1'); + expectedOutput.clear(); + } + REQUIRE(output.str() == expectedOutput); +} + TEST_CASE("ReporterReadLine", "[PromptFlow]") { auto response = GENERATE("", " text \t", "0", "invalid", "99999999999999999999999999"); @@ -169,6 +232,7 @@ TEST_CASE("ReporterReadLine", "[PromptFlow]") std::ostringstream output; Execution::Reporter reporter{ output, input }; reporter.SetConsoleStreamsForTest(true); + reporter.SetLevelMask(Execution::Reporter::Level::Info, GENERATE(false, true)); REQUIRE(reporter.ReadLine() == response); REQUIRE(input.peek() == 'n'); @@ -186,6 +250,11 @@ TEST_CASE("ReporterReadLine_InputFailure", "[PromptFlow]") { REQUIRE_THROWS_HR(reporter.ReadLine(), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); } + SECTION("Redirected streams") + { + reporter.SetConsoleStreamsForTest(false); + REQUIRE_THROWS_HR(reporter.ReadLine(), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); + } SECTION("Cancelled input failure") { int checks = 0; @@ -259,11 +328,13 @@ TEST_CASE("ReporterReadLine_CancelPendingRead", "[PromptFlow]") } }; + bool integerPrompt = GENERATE(false, true); PipeInputBuffer buffer; std::istream input{ &buffer }; std::ostringstream output; TestContext context{ output, input }; context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); wil::unique_event finished{ wil::EventOptions::ManualReset }; bool timedOut = false; std::thread cancel([&]() @@ -289,14 +360,23 @@ TEST_CASE("ReporterReadLine_CancelPendingRead", "[PromptFlow]") watchdog.join(); }); - REQUIRE_FALSE(context.Reporter.ReadLine([&]() { return context.IsTerminated(); })); + auto isCancelled = [&]() { return context.IsTerminated(); }; + if (integerPrompt) + { + REQUIRE_FALSE(context.Reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), + Execution::Reporter::Level::Info, Resource::String::NumberedSelectionInvalid, isCancelled)); + } + else + { + REQUIRE_FALSE(context.Reporter.ReadLine(isCancelled)); + } finished.SetEvent(); cancel.join(); watchdog.join(); join.release(); REQUIRE_FALSE(timedOut); REQUIRE_TERMINATED_WITH(context, E_ABORT); - REQUIRE(output.str().empty()); + REQUIRE(output.str() == (integerPrompt ? Resource::String::NumberedSelectionPrompt(2).get() + " " : std::string{})); } TEST_CASE("PackageSelection_ConsoleStreams", "[PackageSelection][PromptFlow]") @@ -314,6 +394,7 @@ TEST_CASE("PackageSelection_ConsoleStreams", "[PackageSelection][PromptFlow]") TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow]") { + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning); std::istringstream input{ "1\n" }; std::ostringstream output; Execution::Reporter reporter{ output, input }; @@ -323,9 +404,9 @@ TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow { reporter.SetConsoleStreamsForTest(false); } - SECTION("Hidden informational output") + SECTION("Hidden output level") { - reporter.SetLevelMask(Execution::Reporter::Level::Info, false); + reporter.SetLevelMask(level, false); } SECTION("Non-output channel") { @@ -333,9 +414,10 @@ TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow Execution::Reporter::Channel::Disabled)); } - REQUIRE_FALSE(reporter.CanPrompt()); - REQUIRE_THROWS_HR(reporter.ReadLine(), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); + REQUIRE_FALSE(reporter.CanPrompt(level)); + REQUIRE_FALSE(reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), level)); REQUIRE(input.peek() == '1'); + REQUIRE(output.str().empty()); } TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") From 743405e1809a4ed7b885399b2a9fb4fb7839a936 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:13:34 -0700 Subject: [PATCH 21/36] Fix Ctrl+C teardown race in package selection prompts --- src/AppInstallerCLICore/ExecutionReporter.cpp | 34 ++++++- src/AppInstallerCLICore/ExecutionReporter.h | 1 + src/AppInstallerCLITests/PromptFlow.cpp | 92 ++++++++++++++++++- src/AppInstallerCLITests/TestHooks.h | 19 ++++ 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index 2c709aecb5..4af7309c8b 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -11,6 +11,16 @@ namespace AppInstaller::CLI::Execution using namespace Settings; using namespace VirtualTerminal; +#ifndef AICLI_DISABLE_TEST_HOOKS + using ReadConsoleFunction = std::function; + static ReadConsoleFunction* s_readConsoleOverride = nullptr; + + void TestHook_SetReadConsole_Override(ReadConsoleFunction* value) + { + s_readConsoleOverride = value; + } +#endif + const Sequence& HelpCommandEmphasis = TextFormat::Foreground::Bright; const Sequence& HelpArgumentEmphasis = TextFormat::Foreground::Bright; const Sequence& ManifestInfoEmphasis = TextFormat::Foreground::Bright; @@ -26,6 +36,17 @@ namespace AppInstaller::CLI::Execution namespace { + BOOL ReadConsoleChunk(wchar_t* buffer, DWORD size, DWORD* charactersRead) + { +#ifndef AICLI_DISABLE_TEST_HOOKS + if (s_readConsoleOverride) + { + return (*s_readConsoleOverride)(buffer, size, charactersRead); + } +#endif + return ReadConsoleW(GetStdHandle(STD_INPUT_HANDLE), buffer, size, charactersRead, nullptr); + } + DWORD GetStdHandleType(DWORD stdHandle) { DWORD result = FILE_TYPE_UNKNOWN; @@ -204,6 +225,7 @@ namespace AppInstaller::CLI::Execution DWORD readError = ERROR_SUCCESS; ProgressCallback progress; wil::unique_event readCompleted{ wil::EventOptions::ManualReset }; + wil::unique_event cancellationCompleted{ wil::EventOptions::ManualReset }; auto cancellation = progress.SetCancellationFunction([&]() { // Retry until the read ends to cover cancellation immediately before it starts. @@ -218,6 +240,7 @@ namespace AppInstaller::CLI::Execution } } } + cancellationCompleted.SetEvent(); }); SetProgressCallback(&progress); { @@ -237,7 +260,7 @@ namespace AppInstaller::CLI::Execution DWORD charactersRead = 0; SetLastError(ERROR_SUCCESS); // The CRT loses ERROR_OPERATION_ABORTED when Ctrl+C ends a console read. - bool succeeded = ReadConsoleW(GetStdHandle(STD_INPUT_HANDLE), buffer, ARRAYSIZE(buffer), &charactersRead, nullptr); + bool succeeded = ReadConsoleChunk(buffer, ARRAYSIZE(buffer), &charactersRead); readError = GetLastError(); readSucceeded = succeeded && charactersRead != 0; if (!readSucceeded || readError == ERROR_OPERATION_ABORTED) @@ -246,6 +269,15 @@ namespace AppInstaller::CLI::Execution } consoleResponse.append(buffer, charactersRead); } while (consoleResponse.back() != L'\n'); + if (readError == ERROR_OPERATION_ABORTED) + { + readCompleted.SetEvent(); + // The console read can end before the Ctrl+C handler records cancellation. + if (!isCancelled || !isCancelled()) + { + cancellationCompleted.wait(); + } + } response = Utility::ConvertToUTF8(consoleResponse); readSucceeded = readSucceeded && response.find('\x1a') == std::string::npos; } diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index e2dfe8becb..6789ff1eb2 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -121,6 +121,7 @@ namespace AppInstaller::CLI::Execution #ifndef AICLI_DISABLE_TEST_HOOKS void SetConsoleStreamsForTest(bool value) { m_consoleStreams = value; } + void SetInputStreamFileTypeForTest(DWORD value) { m_inStreamFileType = value; } #endif // Prompts for a non-negative integer; returns nullopt if unavailable or cancelled. diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 5e2c586629..f50eaf178f 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -260,7 +260,7 @@ TEST_CASE("ReporterReadLine_InputFailure", "[PromptFlow]") int checks = 0; REQUIRE_FALSE(reporter.ReadLine([&]() { return ++checks == 3; })); } - SECTION("Console read aborts before the signal handler runs") + SECTION("Aborted stream read") { struct AbortedInputBuffer : std::streambuf { @@ -379,6 +379,96 @@ TEST_CASE("ReporterReadLine_CancelPendingRead", "[PromptFlow]") REQUIRE(output.str() == (integerPrompt ? Resource::String::NumberedSelectionPrompt(2).get() + " " : std::string{})); } +TEST_CASE("ReporterReadLine_ConsoleCancellationWaitsForHandler", "[PromptFlow]") +{ + bool integerPrompt = GENERATE(false, true); + BOOL readSucceeded = GENERATE(FALSE, TRUE); + std::istringstream input; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetInputStreamFileTypeForTest(FILE_TYPE_CHAR); + context.Reporter.SetStyle(VisualStyle::NoVT); + wil::unique_event readAborted{ wil::EventOptions::ManualReset }; + wil::unique_event readReturned{ wil::EventOptions::ManualReset }; + size_t readCount = 0; + TestHook::SetReadConsole_Override readConsoleOverride{ [&](wchar_t*, DWORD, DWORD* charactersRead) + { + ++readCount; + *charactersRead = 0; + readAborted.SetEvent(); + SetLastError(ERROR_OPERATION_ABORTED); + return readSucceeded; + } }; + + bool cancelled = false; + std::exception_ptr exception; + auto isCancelled = [&]() { return context.IsTerminated(); }; + std::thread reader([&]() + { + try + { + if (integerPrompt) + { + cancelled = !context.Reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), + Execution::Reporter::Level::Info, Resource::String::NumberedSelectionInvalid, isCancelled); + } + else + { + cancelled = !context.Reporter.ReadLine(isCancelled); + } + } + catch (...) + { + exception = std::current_exception(); + } + readReturned.SetEvent(); + }); + auto join = wil::scope_exit([&]() + { + context.Cancel(AppInstaller::CancelReason::Abort); + reader.join(); + }); + + bool aborted = readAborted.wait(5000); + bool returnedBeforeCancellation = readReturned.wait(100); + context.Cancel(AppInstaller::CancelReason::CtrlCSignal); + reader.join(); + join.release(); + if (exception) + { + std::rethrow_exception(exception); + } + + REQUIRE(aborted); + REQUIRE_FALSE(returnedBeforeCancellation); + REQUIRE(cancelled); + REQUIRE(readCount == size_t{1}); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str() == (integerPrompt ? Resource::String::NumberedSelectionPrompt(2).get() + " " : std::string{})); +} + +TEST_CASE("ReporterReadLine_ConsoleCancellationAlreadyRecorded", "[PromptFlow]") +{ + BOOL readSucceeded = GENERATE(FALSE, TRUE); + std::istringstream input; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetInputStreamFileTypeForTest(FILE_TYPE_CHAR); + TestHook::SetReadConsole_Override readConsoleOverride{ [&](wchar_t*, DWORD, DWORD* charactersRead) + { + context.Terminate(E_ABORT); + *charactersRead = 0; + SetLastError(ERROR_OPERATION_ABORTED); + return readSucceeded; + } }; + + REQUIRE_FALSE(context.Reporter.ReadLine([&]() { return context.IsTerminated(); })); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str().empty()); +} + TEST_CASE("PackageSelection_ConsoleStreams", "[PackageSelection][PromptFlow]") { Execution::Reporter reporter; diff --git a/src/AppInstallerCLITests/TestHooks.h b/src/AppInstallerCLITests/TestHooks.h index 38dcb01c6e..396eafc9e1 100644 --- a/src/AppInstallerCLITests/TestHooks.h +++ b/src/AppInstallerCLITests/TestHooks.h @@ -79,6 +79,9 @@ namespace AppInstaller namespace CLI::Execution { void TestHook_SetConsoleWidth_Override(std::optional* value); + + using ReadConsoleFunction = std::function; + void TestHook_SetReadConsole_Override(ReadConsoleFunction* value); } namespace CLI::Workflow @@ -388,6 +391,22 @@ namespace TestHook std::optional m_width; }; + struct SetReadConsole_Override + { + SetReadConsole_Override(AppInstaller::CLI::Execution::ReadConsoleFunction function) : m_function(std::move(function)) + { + AppInstaller::CLI::Execution::TestHook_SetReadConsole_Override(&m_function); + } + + ~SetReadConsole_Override() + { + AppInstaller::CLI::Execution::TestHook_SetReadConsole_Override(nullptr); + } + + private: + AppInstaller::CLI::Execution::ReadConsoleFunction m_function; + }; + struct SetGetFontRegistryRoot_Override { SetGetFontRegistryRoot_Override(std::function function) From a124a7ca8b27a9cd3e179a807921a0cb3beb8ba5 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:43:26 -0700 Subject: [PATCH 22/36] Add verbose logging when selection prompts are unavailable --- src/AppInstallerCLICore/Workflows/PromptFlow.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 3d4f3b1e60..927d35a2e8 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -397,8 +397,13 @@ namespace AppInstaller::CLI::Workflow const size_t count = m_table.GetNonEmptyRowCount(0); THROW_HR_IF(E_INVALIDARG, !count); - if (!IsInteractivityAllowed(context) || !context.Reporter.CanPrompt()) + if (!IsInteractivityAllowed(context)) + { + return; + } + if (!context.Reporter.CanPrompt()) { + AICLI_LOG(CLI, Verbose, << "Skipping selection prompt. Console streams or output are unavailable."); return; } From 4b9462179d10f0194bd8a1ca6802cff548411713 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:33:57 -0700 Subject: [PATCH 23/36] Restore the original interactivity comment --- src/AppInstallerCLICore/Workflows/PromptFlow.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 927d35a2e8..79d7f2c086 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -15,6 +15,11 @@ namespace AppInstaller::CLI::Workflow { bool IsInteractivityAllowed(Execution::Context& context) { + // Interactivity can be disabled for several reasons: + // * We are running in a non-interactive context (e.g., COM call) + // * It is disabled in the settings + // * It was disabled from the command line + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) { AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); From 7f23ccc54f10a6c2a426ed71080bd94d32c8e44a Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:07:09 -0700 Subject: [PATCH 24/36] Rename the prompt selection data key to SelectedIndex --- src/AppInstallerCLICore/ExecutionContextData.h | 4 ++-- src/AppInstallerCLICore/Workflows/PromptFlow.cpp | 4 ++-- src/AppInstallerCLICore/Workflows/PromptFlow.h | 2 +- src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 2 +- src/AppInstallerCLITests/PromptFlow.cpp | 12 ++++++------ 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/AppInstallerCLICore/ExecutionContextData.h b/src/AppInstallerCLICore/ExecutionContextData.h index 103a47d3ea..40cf11537d 100644 --- a/src/AppInstallerCLICore/ExecutionContextData.h +++ b/src/AppInstallerCLICore/ExecutionContextData.h @@ -69,7 +69,7 @@ namespace AppInstaller::CLI::Execution RepairString, MsixDigests, InstallerDownloadAuthenticators, - PromptSelection, + SelectedIndex, Max }; @@ -102,7 +102,7 @@ namespace AppInstaller::CLI::Execution }; template <> - struct DataMapping + struct DataMapping { using value_t = std::optional; }; diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 79d7f2c086..ee4d408507 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -397,7 +397,7 @@ namespace AppInstaller::CLI::Workflow void PromptForSelection::operator()(Execution::Context& context) const { - context.Add(std::optional{}); + context.Add(std::optional{}); AICLI_RETURN_IF_TERMINATED(context); const size_t count = m_table.GetNonEmptyRowCount(0); THROW_HR_IF(E_INVALIDARG, !count); @@ -438,7 +438,7 @@ namespace AppInstaller::CLI::Workflow out << Resource::String::Cancelled << std::endl; AICLI_TERMINATE_CONTEXT(E_ABORT); } - context.Add(std::optional{ static_cast(selection - 1) }); + context.Add(std::optional{ static_cast(selection - 1) }); return; } diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 6f5ac6ffc7..8824d1928f 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -9,7 +9,7 @@ namespace AppInstaller::CLI::Workflow // Prompts for a numbered choice; rows with an empty first column are continuations. // Required Args: None // Inputs: None - // Outputs: PromptSelection (zero-based index, or nullopt if prompting is unavailable) + // Outputs: SelectedIndex (zero-based index, or nullopt if prompting is unavailable) struct PromptForSelection : public WorkflowTask { PromptForSelection(Execution::TableOutputBase& table, Resource::LocString title, Resource::LocString invalid) : diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 9d45fdea9f..07c27cc8dc 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1473,7 +1473,7 @@ namespace AppInstaller::CLI::Workflow } context << PromptForSelection(table, title, Resource::String::NumberedSelectionInvalid); AICLI_RETURN_IF_TERMINATED(context); - selection = context.Get(); + selection = context.Get(); } if (selection) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index f50eaf178f..3e1905ccf7 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -34,7 +34,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::NumberedSelectionInvalid); - auto selection = context.Get(); + auto selection = context.Get(); if (std::string_view{ response } == "0") { REQUIRE_FALSE(selection); @@ -68,7 +68,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::NumberedSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); - REQUIRE(context.Get() == 1); + REQUIRE(context.Get() == 1); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); @@ -93,7 +93,7 @@ TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") context << PromptForSelection(table, text("Choose a value"), text("Try again")); REQUIRE_FALSE(context.IsTerminated()); - REQUIRE(context.Get() == 1); + REQUIRE(context.Get() == 1); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); REQUIRE_FALSE(prompt.empty()); REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\n" + prompt + " Try again\n" + prompt + " "); @@ -106,7 +106,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") std::ostringstream output; TestContext context{ output, input }; context.Reporter.SetConsoleStreamsForTest(true); - context.Add(std::optional{0}); + context.Add(std::optional{0}); Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); table.OutputLine({ "First" }); @@ -134,7 +134,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::NumberedSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); - REQUIRE_FALSE(context.Get()); + REQUIRE_FALSE(context.Get()); REQUIRE(output.str().empty()); REQUIRE(input.peek() == '1'); } @@ -160,7 +160,7 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") Resource::String::NumberedSelectionInvalid); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); - REQUIRE_FALSE(context.Get()); + REQUIRE_FALSE(context.Get()); } TEST_CASE("ReporterPromptForIntegerResponse", "[PromptFlow]") From 4e13a1746690d9206f64264dd6e2ddf6563044e3 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:38:32 -0700 Subject: [PATCH 25/36] Move integer range validation into Reporter --- src/AppInstallerCLICore/ExecutionReporter.cpp | 12 ++- src/AppInstallerCLICore/ExecutionReporter.h | 4 + .../Workflows/PromptFlow.cpp | 33 +++------ src/AppInstallerCLITests/PromptFlow.cpp | 73 +++++++++++++++++++ 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index 4af7309c8b..9286f22998 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -4,6 +4,7 @@ #include "ExecutionReporter.h" #include #include +#include namespace AppInstaller::CLI::Execution @@ -300,6 +301,15 @@ namespace AppInstaller::CLI::Execution std::optional Reporter::PromptForIntegerResponse(Resource::LocString message, Level level, Resource::LocString invalid, std::function isCancelled) { + return PromptForIntegerResponseWithinRange(std::move(message), 0, std::numeric_limits::max(), + level, std::move(invalid), std::move(isCancelled)); + } + + std::optional Reporter::PromptForIntegerResponseWithinRange(Resource::LocString message, uint64_t minimum, uint64_t maximum, + Level level, Resource::LocString invalid, std::function isCancelled) + { + THROW_HR_IF(E_INVALIDARG, minimum > maximum); + if (!CanPrompt(level)) { AICLI_LOG(CLI, Verbose, << "Skipping integer prompt. Console streams or output are unavailable."); @@ -324,7 +334,7 @@ namespace AppInstaller::CLI::Execution Utility::Trim(*response); uint64_t value = 0; auto result = std::from_chars(response->data(), response->data() + response->size(), value); - if (result.ec == std::errc{} && result.ptr == response->data() + response->size()) + if (result.ec == std::errc{} && result.ptr == response->data() + response->size() && value >= minimum && value <= maximum) { return value; } diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index 6789ff1eb2..52f03253f1 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -128,6 +128,10 @@ namespace AppInstaller::CLI::Execution std::optional PromptForIntegerResponse(Resource::LocString message, Level level = Level::Info, Resource::LocString invalid = Resource::String::NumberedSelectionInvalid, std::function isCancelled = {}); + // Prompts for an integer in [minimum, maximum]; returns nullopt if unavailable or cancelled. + std::optional PromptForIntegerResponseWithinRange(Resource::LocString message, uint64_t minimum, uint64_t maximum, + Level level = Level::Info, Resource::LocString invalid = Resource::String::NumberedSelectionInvalid, std::function isCancelled = {}); + // Prompts the user, return true if they consented. bool PromptForBoolResponse(Resource::LocString message, Level level = Level::Info, bool resultIfDisabled = false); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index ee4d408507..6eab71cad9 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -418,32 +418,17 @@ namespace AppInstaller::CLI::Workflow out << std::endl; const auto prompt = Resource::String::NumberedSelectionPrompt(count); - for (;;) + AICLI_RETURN_IF_TERMINATED(context); + auto response = context.Reporter.PromptForIntegerResponseWithinRange(prompt, 0, count, Reporter::Level::Info, m_invalid, + [&]() { return context.IsTerminated(); }); + AICLI_RETURN_IF_TERMINATED(context); + if (!response || *response == 0) { - AICLI_RETURN_IF_TERMINATED(context); - auto response = context.Reporter.PromptForIntegerResponse(prompt, Reporter::Level::Info, m_invalid, - [&]() { return context.IsTerminated(); }); - AICLI_RETURN_IF_TERMINATED(context); - if (!response) - { - out << Resource::String::Cancelled << std::endl; - AICLI_TERMINATE_CONTEXT(E_ABORT); - } - - uint64_t selection = *response; - if (selection <= count) - { - if (!selection) - { - out << Resource::String::Cancelled << std::endl; - AICLI_TERMINATE_CONTEXT(E_ABORT); - } - context.Add(std::optional{ static_cast(selection - 1) }); - return; - } - - out << m_invalid << std::endl; + out << Resource::String::Cancelled << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); } + + context.Add(std::optional{ static_cast(*response - 1) }); } void HandleSourceAgreements::operator()(Execution::Context& context) const diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 3e1905ccf7..05d3fb3e08 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -11,6 +11,7 @@ #include #include #include +#include using namespace TestCommon; using namespace AppInstaller::CLI; @@ -225,6 +226,77 @@ TEST_CASE("ReporterPromptForIntegerResponse_InputFailure", "[PromptFlow]") REQUIRE(output.str() == expectedOutput); } +TEST_CASE("ReporterPromptForIntegerResponseWithinRange", "[PromptFlow]") +{ + const uint64_t minimum = GENERATE(uint64_t{0}, uint64_t{2}, std::numeric_limits::max() - 2); + const uint64_t maximum = minimum + GENERATE(uint64_t{0}, uint64_t{2}); + const uint64_t response = GENERATE_COPY(minimum, maximum, minimum + (maximum - minimum) / 2); + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); + std::istringstream input{ std::to_string(response) + "\nnext\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + reporter.SetLevelMask(Execution::Reporter::Level::All, false); + reporter.SetLevelMask(level); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + auto result = reporter.PromptForIntegerResponseWithinRange(message, minimum, maximum, level); + REQUIRE(result.has_value()); + REQUIRE(*result == response); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str() == "Number: "); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange_InvalidInput", "[PromptFlow]") +{ + auto response = GENERATE("wrong", "0", "1", "5", "18446744073709551615", "18446744073709551616"); + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); + std::istringstream input{ std::string{ response } + "\n3\nnext\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + reporter.SetLevelMask(Execution::Reporter::Level::All, false); + reporter.SetLevelMask(level); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + const Resource::LocString invalid{ AppInstaller::Utility::LocIndString{ std::string_view{ "Try again" } } }; + + REQUIRE(reporter.PromptForIntegerResponseWithinRange(message, 2, 4, level, invalid) == uint64_t{3}); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str() == "Number: Try again\nNumber: "); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange_CancelRetry", "[PromptFlow]") +{ + auto response = GENERATE("1", "5"); + std::istringstream input{ std::string{ response } + "\n3\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + const Resource::LocString invalid{ AppInstaller::Utility::LocIndString{ std::string_view{ "Try again" } } }; + auto isCancelled = [&]() { return output.str().find("Try again\n") != std::string::npos; }; + + REQUIRE_FALSE(reporter.PromptForIntegerResponseWithinRange(message, 2, 4, Execution::Reporter::Level::Info, invalid, isCancelled)); + REQUIRE(input.peek() == '3'); + REQUIRE(output.str() == "Number: Try again\n"); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange_InvalidRange", "[PromptFlow]") +{ + std::istringstream input{ "1\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(GENERATE(false, true)); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + REQUIRE_THROWS_HR(reporter.PromptForIntegerResponseWithinRange(message, 2, 1), E_INVALIDARG); + REQUIRE(input.peek() == '1'); + REQUIRE(output.str().empty()); +} + TEST_CASE("ReporterReadLine", "[PromptFlow]") { auto response = GENERATE("", " text \t", "0", "invalid", "99999999999999999999999999"); @@ -506,6 +578,7 @@ TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow REQUIRE_FALSE(reporter.CanPrompt(level)); REQUIRE_FALSE(reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), level)); + REQUIRE_FALSE(reporter.PromptForIntegerResponseWithinRange(Resource::String::NumberedSelectionPrompt(2), 0, 2, level)); REQUIRE(input.peek() == '1'); REQUIRE(output.str().empty()); } From 4fc90793279f9cb8b84310a0f168d4199aac84e6 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:50:53 -0700 Subject: [PATCH 26/36] Always show package identity on selection source rows --- doc/specs/#5345 - Interactive package selection.md | 6 +++--- src/AppInstallerCLICore/Workflows/WorkflowBase.cpp | 11 ++--------- src/AppInstallerCLITests/PromptFlow.cpp | 5 ++--- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index e3df602413..d798e2cec8 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -1,7 +1,7 @@ --- -author: AmelBawa-msft +author: AmelBawa-msft, GitHub Copilot created on: 2026-09-28 -last updated: 2026-09-29 +last updated: 2026-09-30 issue id: 5345 --- @@ -68,7 +68,7 @@ For candidates spanning sources: 2 Contoso Editor Contoso.Editor 2.4.0 private ``` -Each source row uses that source's package name, ID, and version. Additional sources within one candidate appear on continuation rows without another selection number; name and ID are omitted only when both match the preceding row. Use action-specific introductory text for viewing or downloading. Do not add another confirmation after selection. Existing consent prompts still apply. +Each source row always shows that source's package name, ID, and version. Additional sources within one candidate appear on continuation rows without another selection number. Use action-specific introductory text for viewing or downloading. Do not add another confirmation after selection. Existing consent prompts still apply. When prompting is unavailable, retain the candidate list and explain: diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 07c27cc8dc..7f0b32bd81 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1426,18 +1426,12 @@ namespace AppInstaller::CLI::Workflow }); } bool firstSource = true; - std::pair previousIdentity; for (const auto& available : availablePackages) { - std::pair identity{ - available->GetProperty(PackageProperty::Name), - available->GetProperty(PackageProperty::Id) - }; - bool repeatedIdentity = !firstSource && identity == previousIdentity; Execution::TableOutput<5>::line_t line{ firstSource ? std::to_string(i + 1) : ""s, - repeatedIdentity ? ""s : identity.first, - repeatedIdentity ? ""s : identity.second, + available->GetProperty(PackageProperty::Name), + available->GetProperty(PackageProperty::Id), unavailable, unavailable }; @@ -1455,7 +1449,6 @@ namespace AppInstaller::CLI::Workflow } lines.emplace_back(std::move(line)); firstSource = false; - previousIdentity = std::move(identity); } } diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 05d3fb3e08..f7ca7a73e7 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -883,9 +883,8 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); std::vector> expectedRows{ { "1", "PublicName", "Public.App", "1.0", "FirstSource" }, - differentName || differentId ? std::vector{ secondName, secondId, "2.0", "SecondSource" } : - std::vector{ "2.0", "SecondSource" }, - { missingMetadata ? unavailable : "3.0", missingMetadata ? unavailable : "SecondSource" }, + { secondName, secondId, "2.0", "SecondSource" }, + { secondName, secondId, missingMetadata ? unavailable : "3.0", missingMetadata ? unavailable : "SecondSource" }, { "2", "OtherName", "Other.App", "4.0", "FirstSource" } }; for (const auto& expectedRow : expectedRows) From b4b914defe1eeacd0c79c32ec44979482c5884fc Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:30:50 -0700 Subject: [PATCH 27/36] Represent continuation rows explicitly in table output --- src/AppInstallerCLICore/TableOutput.cpp | 39 +++++++--- src/AppInstallerCLICore/TableOutput.h | 18 ++++- .../Workflows/PromptFlow.cpp | 2 +- .../Workflows/PromptFlow.h | 2 +- .../Workflows/WorkflowBase.cpp | 34 ++++---- src/AppInstallerCLITests/PromptFlow.cpp | 10 +-- src/AppInstallerCLITests/TableOutput.cpp | 77 +++++++++++++++++++ 7 files changed, 146 insertions(+), 36 deletions(-) diff --git a/src/AppInstallerCLICore/TableOutput.cpp b/src/AppInstallerCLICore/TableOutput.cpp index b92f6644dc..8364ee2cda 100644 --- a/src/AppInstallerCLICore/TableOutput.cpp +++ b/src/AppInstallerCLICore/TableOutput.cpp @@ -2,6 +2,7 @@ // Licensed under the MIT License. #include "pch.h" #include "TableOutput.h" +#include namespace AppInstaller::CLI::Execution { @@ -19,7 +20,14 @@ namespace AppInstaller::CLI::Execution void TableOutputBase::OutputLine(std::vector line) { THROW_HR_IF(E_INVALIDARG, line.size() != m_columns.size()); - m_buffer.emplace_back(std::move(line)); + m_buffer.push_back({ std::move(line) }); + } + + void TableOutputBase::OutputContinuationLine(std::vector line) + { + THROW_HR_IF(E_INVALIDARG, m_buffer.empty() || line.empty() || line.front() != m_buffer.back().Values.front()); + OutputLine(std::move(line)); + m_buffer.back().IsContinuation = true; } void TableOutputBase::Complete() @@ -30,22 +38,30 @@ namespace AppInstaller::CLI::Execution } } + size_t TableOutputBase::GetPrimaryRowCount() const + { + return static_cast(std::count_if(m_buffer.begin(), m_buffer.end(), [](const auto& row) + { + return !row.IsContinuation; + })); + } + size_t TableOutputBase::GetNonEmptyRowCount(size_t column) const { THROW_HR_IF(E_INVALIDARG, column >= m_columns.size()); - return static_cast(std::count_if(m_buffer.begin(), m_buffer.end(), [column](const auto& line) + return static_cast(std::count_if(m_buffer.begin(), m_buffer.end(), [column](const auto& row) { - return !line[column].empty(); + return !row.Values[column].empty(); })); } void TableOutputBase::EvaluateAndFlushBuffer() { - for (const auto& line : m_buffer) + for (const auto& row : m_buffer) { for (size_t i = 0; i < m_columns.size(); ++i) { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(line[i])); + m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(row.Values[i])); } } @@ -94,14 +110,14 @@ namespace AppInstaller::CLI::Execution } OutputLineToStream(header); m_reporter.Info() << std::string(totalRequired, '-') << std::endl; - for (const auto& line : m_buffer) + for (const auto& row : m_buffer) { - OutputLineToStream(line); + OutputLineToStream(row.Values, row.IsContinuation); } m_bufferEvaluated = true; } - void TableOutputBase::OutputLineToStream(const std::vector& line) + void TableOutputBase::OutputLineToStream(const std::vector& line, bool isContinuation) { auto out = m_reporter.Info(); for (size_t i = 0; i < m_columns.size(); ++i) @@ -109,11 +125,12 @@ namespace AppInstaller::CLI::Execution const auto& column = m_columns[i]; if (column.MaxLength) { - size_t valueLength = Utility::UTF8ColumnWidth(line[i]); + std::string_view value = isContinuation && i == 0 ? std::string_view{} : std::string_view{ line[i] }; + size_t valueLength = Utility::UTF8ColumnWidth(value); if (valueLength > column.MaxLength) { size_t actualWidth; - out << Utility::UTF8TrimRightToColumnWidth(line[i], column.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; + out << Utility::UTF8TrimRightToColumnWidth(value, column.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; // Wide characters can leave one column unused before the ellipsis. if (actualWidth != column.MaxLength - 1) { @@ -126,7 +143,7 @@ namespace AppInstaller::CLI::Execution } else { - out << line[i]; + out << value; if (column.SpaceAfter) { out << std::string(column.MaxLength - valueLength + 1, ' '); diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index f61afbab4a..25283de5f4 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -18,11 +18,20 @@ namespace AppInstaller::CLI::Execution // Buffers rows until Complete() computes column widths and renders the table. void OutputLine(std::vector line); + // Requires the preceding row's first-column value; hides it only when rendered. + void OutputContinuationLine(std::vector line); void Complete(); bool IsEmpty() const { return m_buffer.empty(); } + size_t GetPrimaryRowCount() const; size_t GetNonEmptyRowCount(size_t column) const; private: + struct Row + { + std::vector Values; + bool IsContinuation = false; + }; + // A column in the table. struct Column { @@ -34,11 +43,11 @@ namespace AppInstaller::CLI::Execution Reporter& m_reporter; std::vector m_columns; - std::vector> m_buffer; + std::vector m_buffer; bool m_bufferEvaluated = false; void EvaluateAndFlushBuffer(); - void OutputLineToStream(const std::vector& line); + void OutputLineToStream(const std::vector& line, bool isContinuation = false); }; // Retains fixed-size headers and rows for existing table callers. @@ -55,5 +64,10 @@ namespace AppInstaller::CLI::Execution { TableOutputBase::OutputLine({ std::make_move_iterator(line.begin()), std::make_move_iterator(line.end()) }); } + + void OutputContinuationLine(line_t&& line) + { + TableOutputBase::OutputContinuationLine({ std::make_move_iterator(line.begin()), std::make_move_iterator(line.end()) }); + } }; } diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 6eab71cad9..057d05b520 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -399,7 +399,7 @@ namespace AppInstaller::CLI::Workflow { context.Add(std::optional{}); AICLI_RETURN_IF_TERMINATED(context); - const size_t count = m_table.GetNonEmptyRowCount(0); + const size_t count = m_table.GetPrimaryRowCount(); THROW_HR_IF(E_INVALIDARG, !count); if (!IsInteractivityAllowed(context)) diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 8824d1928f..6eecff5551 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -6,7 +6,7 @@ namespace AppInstaller::CLI::Workflow { - // Prompts for a numbered choice; rows with an empty first column are continuations. + // Prompts for a numbered choice among the table's primary rows. // Required Args: None // Inputs: None // Outputs: SelectedIndex (zero-based index, or nullopt if prompting is unavailable) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 7f0b32bd81..25fdd391ff 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1409,7 +1409,14 @@ namespace AppInstaller::CLI::Workflow auto title = m_operationType == OperationType::Install ? Resource::String::PackageSelectionInstall : m_operationType == OperationType::Download ? Resource::String::PackageSelectionDownload : Resource::String::PackageSelectionShow; - std::vector::line_t> lines; + Execution::TableOutput<5> table(context.Reporter, + { + Resource::LocString{ Utility::LocIndString{ "#"sv } }, + Resource::String::SearchName, + Resource::String::SearchId, + Resource::String::SearchVersion, + Resource::String::SearchSource + }); const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); for (size_t i = 0; i < searchResult.Matches.size(); ++i) { @@ -1417,7 +1424,7 @@ namespace AppInstaller::CLI::Workflow auto availablePackages = package->GetAvailable(); if (availablePackages.empty()) { - lines.emplace_back(Execution::TableOutput<5>::line_t{ + table.OutputLine({ std::to_string(i + 1), package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id), @@ -1429,7 +1436,7 @@ namespace AppInstaller::CLI::Workflow for (const auto& available : availablePackages) { Execution::TableOutput<5>::line_t line{ - firstSource ? std::to_string(i + 1) : ""s, + std::to_string(i + 1), available->GetProperty(PackageProperty::Name), available->GetProperty(PackageProperty::Id), unavailable, @@ -1447,23 +1454,18 @@ namespace AppInstaller::CLI::Workflow { line[4] = std::move(sourceName); } - lines.emplace_back(std::move(line)); + if (firstSource) + { + table.OutputLine(std::move(line)); + } + else + { + table.OutputContinuationLine(std::move(line)); + } firstSource = false; } } - Execution::TableOutput<5> table(context.Reporter, - { - Resource::LocString{ Utility::LocIndString{ "#"sv } }, - Resource::String::SearchName, - Resource::String::SearchId, - Resource::String::SearchVersion, - Resource::String::SearchSource - }); - for (auto& line : lines) - { - table.OutputLine(std::move(line)); - } context << PromptForSelection(table, title, Resource::String::NumberedSelectionInvalid); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index f7ca7a73e7..02d265825b 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -62,7 +62,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") table.OutputLine({ "First", "FirstSource" }); if (GENERATE(false, true)) { - table.OutputLine({ "", "SecondSource" }); + table.OutputContinuationLine({ "First", "SecondSource" }); } table.OutputLine({ "Second", "FirstSource" }); @@ -152,10 +152,10 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") if (count) { table.OutputLine({ "First" }); - } - if (GENERATE(false, true)) - { - table.OutputLine({ "" }); + if (GENERATE(false, true)) + { + table.OutputContinuationLine({ "First" }); + } } PromptForSelection prompt(table, Resource::String::PackageSelectionInstall, Resource::String::NumberedSelectionInvalid); diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index 6d6b9f980f..e603552519 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -33,10 +33,16 @@ TEST_CASE("TableOutput_DynamicMatchesTyped", "[tableoutput]") TableOutputBase dynamic(dynamicReporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Id") }); typed.OutputLine({ "LongPackageName", "", "test.id" }); dynamic.OutputLine({ "LongPackageName", "", "test.id" }); + if (GENERATE(false, true)) + { + typed.OutputContinuationLine({ "LongPackageName", "", "other.id" }); + dynamic.OutputContinuationLine({ "LongPackageName", "", "other.id" }); + } typed.Complete(); dynamic.Complete(); REQUIRE_FALSE(dynamic.IsEmpty()); + REQUIRE(dynamic.GetPrimaryRowCount() == size_t{1}); REQUIRE(dynamicOutput.str() == typedOutput.str()); dynamic.Complete(); REQUIRE(dynamicOutput.str() == typedOutput.str()); @@ -61,6 +67,7 @@ TEST_CASE("TableOutput_NonEmptyRowCount", "[tableoutput]") TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; Reporter reporter(output, input); TableOutput<2> table(reporter, { MakeHeader("Choice"), MakeHeader("Source") }); + REQUIRE(table.GetPrimaryRowCount() == size_t{0}); REQUIRE(table.GetNonEmptyRowCount(0) == size_t{0}); REQUIRE(table.GetNonEmptyRowCount(1) == size_t{0}); REQUIRE_THROWS_HR(table.GetNonEmptyRowCount(2), E_INVALIDARG); @@ -69,15 +76,85 @@ TEST_CASE("TableOutput_NonEmptyRowCount", "[tableoutput]") table.OutputLine({ "", "SecondSource" }); table.OutputLine({ "2", "ThirdSource" }); table.OutputLine({ "", "" }); + REQUIRE(table.GetPrimaryRowCount() == size_t{4}); REQUIRE(table.GetNonEmptyRowCount(0) == size_t{2}); REQUIRE(table.GetNonEmptyRowCount(1) == size_t{3}); REQUIRE(output.str().empty()); table.Complete(); + REQUIRE(table.GetPrimaryRowCount() == size_t{4}); REQUIRE(table.GetNonEmptyRowCount(0) == size_t{2}); REQUIRE(table.GetNonEmptyRowCount(1) == size_t{3}); } +TEST_CASE("TableOutput_ContinuationRows", "[tableoutput]") +{ + std::ostringstream output; + std::ostringstream expectedOutput; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{GENERATE(size_t{20}, size_t{120})} }; + Reporter reporter(output, input); + Reporter expectedReporter(expectedOutput, input); + reporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + expectedReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + TableOutput<3> table(reporter, { MakeHeader("#"), MakeHeader("Name"), MakeHeader("Source") }); + TableOutput<3> expected(expectedReporter, { MakeHeader("#"), MakeHeader("Name"), MakeHeader("Source") }); + + table.OutputLine({ "1", "FirstName", "FirstSource" }); + table.OutputContinuationLine({ "1", "FirstName", "SecondSource" }); + table.OutputContinuationLine({ "1", "AliasName", "ThirdSource" }); + table.OutputLine({ "2", "SecondName", "FirstSource" }); + table.OutputContinuationLine({ "2", "SecondName", "SecondSource" }); + expected.OutputLine({ "1", "FirstName", "FirstSource" }); + expected.OutputLine({ "", "FirstName", "SecondSource" }); + expected.OutputLine({ "", "AliasName", "ThirdSource" }); + expected.OutputLine({ "2", "SecondName", "FirstSource" }); + expected.OutputLine({ "", "SecondName", "SecondSource" }); + + REQUIRE(table.GetPrimaryRowCount() == size_t{2}); + REQUIRE(table.GetNonEmptyRowCount(0) == size_t{5}); + REQUIRE(output.str().empty()); + table.Complete(); + expected.Complete(); + REQUIRE(output.str() == expectedOutput.str()); + REQUIRE(table.GetPrimaryRowCount() == size_t{2}); + REQUIRE(table.GetNonEmptyRowCount(0) == size_t{5}); + table.Complete(); + REQUIRE(output.str() == expectedOutput.str()); +} + +TEST_CASE("TableOutput_InvalidContinuation", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + Reporter reporter(output, input); + TableOutputBase table(reporter, { MakeHeader("Choice"), MakeHeader("Source") }); + size_t expectedRows = 0; + + SECTION("Without a primary row") + { + REQUIRE_THROWS_HR(table.OutputContinuationLine({ "1", "FirstSource" }), E_INVALIDARG); + } + SECTION("After a primary row") + { + table.OutputLine({ "1", "FirstSource" }); + expectedRows = 1; + SECTION("Invalid dimensions") + { + auto columnCount = GENERATE(size_t{0}, size_t{1}, size_t{3}); + REQUIRE_THROWS_HR(table.OutputContinuationLine(std::vector(columnCount, "1")), E_INVALIDARG); + } + SECTION("Different first-column value") + { + REQUIRE_THROWS_HR(table.OutputContinuationLine({ "2", "SecondSource" }), E_INVALIDARG); + } + } + + REQUIRE(table.GetPrimaryRowCount() == expectedRows); + REQUIRE(table.GetNonEmptyRowCount(0) == expectedRows); + REQUIRE(output.str().empty()); +} + // Test that all rows are buffered and column widths account for values beyond the first 50 rows. // In the old sizing-buffer design, a row at position 55 with a longer value than any of the // first 50 rows would be truncated. The new design buffers every row so no value is clipped. From 8052913b08f03e87e2bcf56b30fc657083d07855 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:04:17 -0700 Subject: [PATCH 28/36] Make interactive package selection experimentally opt-in --- doc/ReleaseNotes.md | 6 +-- doc/Settings.md | 12 +++++ .../#5345 - Interactive package selection.md | 19 +++++-- .../JSON/settings/settings.schema.0.2.json | 5 ++ .../Workflows/WorkflowBase.cpp | 3 +- .../ExperimentalFeature.cpp | 32 +++++++++++ src/AppInstallerCLITests/PromptFlow.cpp | 53 +++++++++++++++++++ .../ExperimentalFeature.cpp | 4 ++ .../Public/winget/ExperimentalFeature.h | 1 + .../Public/winget/UserSettings.h | 2 + src/AppInstallerCommonCore/UserSettings.cpp | 1 + 11 files changed, 130 insertions(+), 8 deletions(-) diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index a637530eb2..c5d973159d 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -2,11 +2,11 @@ ## New Features -### Interactive package selection +### Interactive package selection (experimental) -When multiple packages match a single-package `install`, `show`, or `download` command, WinGet offers a numbered choice in an interactive terminal. Enter a package number to continue or `0` to cancel. +Set `experimentalFeatures.interactivePackageSelection` to `true` in settings to enable numbered choices when multiple packages match a single-package `install`, `show`, or `download` command in an interactive terminal. Enter a package number to continue or `0` to cancel. -Scripts and other noninteractive callers retain the existing ambiguity error. Use `--id --exact --source ` to select a package explicitly, or `--disable-interactivity` to prevent prompts. +This feature is disabled by default. Redirected and noninteractive callers retain the existing ambiguity error. Use `--id --exact --source ` to select a package explicitly, or `--disable-interactivity` to prevent prompts. ### Source priority diff --git a/doc/Settings.md b/doc/Settings.md index ba7c8af041..749a3b5cd8 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -450,3 +450,15 @@ This feature enables support for fonts via `winget settings`. The `winget font l "fonts": true }, ``` + +### interactivePackageSelection + +Enables numbered choices for ambiguous single-package `install`, `show`, and `download` commands, including `show --versions`. Disabled by default. + +```json + "experimentalFeatures": { + "interactivePackageSelection": true + }, +``` + +`--disable-interactivity` and redirected input or output still prevent prompting. diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index d798e2cec8..80cf130614 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -1,7 +1,7 @@ --- author: AmelBawa-msft, GitHub Copilot created on: 2026-09-28 -last updated: 2026-09-30 +last updated: 2026-10-01 issue id: 5345 --- @@ -11,7 +11,7 @@ For [#5345](https://github.com/microsoft/winget-cli/issues/5345) ## Abstract -Let users resolve ambiguous package matches without restarting their command. Single-package `install`, `show`, and `download` offer numbered choices when interactive input and output are available. +Let users resolve ambiguous package matches without restarting their command. An experimental setting enables numbered choices for single-package `install`, `show`, and `download` when interactive input and output are available. ## Inspiration @@ -19,12 +19,23 @@ The same query can match multiple packages, including packages from different so ## Solution Design +This feature is disabled by default. Enable it in settings: + +```json +{ + "experimentalFeatures": { + "interactivePackageSelection": true + } +} +``` + Apply existing search matching and source-priority rules first. If multiple candidates remain, eligible CLI call sites opt into selection. Shared workflows remain noninteractive by default. Display candidates in their existing order with stable, one-indexed numbers. A valid number selects the existing package object without searching again. Preserve command options and continue normal version selection, applicability checks, and agreement handling. | Situation | Behavior | | --- | --- | +| Experimental feature disabled (default) | No selection prompts; retain existing ambiguity errors. | | No match | Existing no-match error. | | One match after existing policy | Continue without prompting. | | Multiple matches for single-package `install`, `show`, or `download` | Prompt if eligible, including `show --versions`. | @@ -41,7 +52,7 @@ Display candidates in their existing order with stable, one-indexed numbers. A v | Upgrade, uninstall, repair, pin, search, list, or completion | Preserve existing behavior. | | COM API, PowerShell cmdlets, or configuration/DSC | No new prompts or API changes. | -The prompt adds no settings, command-line flags, group policies, manifest fields, or schema versions. Existing interactivity settings apply. Package validation pipelines and manifest authoring tools are unchanged; manifest examples and schema snippets are not applicable. +Apart from the experimental setting, the prompt adds no command-line flags, group policies, manifest fields, or schema versions. Existing interactivity controls and the experimental-features group policy apply. Package validation pipelines and manifest authoring tools are unchanged; manifest examples and schema snippets are not applicable. ## UI/UX Design @@ -94,7 +105,7 @@ Selection uses the displayed candidate object rather than re-running a potential ### Compatibility -Only explicitly opted-in, interactive single-package commands change behavior. Scripts retain existing ambiguity errors and can use exact ID and source selectors. +The feature is disabled by default. After opt-in, scripts can preserve ambiguity errors with `--disable-interactivity`, or avoid ambiguity with exact ID and source selectors. ### Performance, Power, and Efficiency diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index de8dc24389..e162aaac12 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -339,6 +339,11 @@ "type": "boolean", "default": false }, + "interactivePackageSelection": { + "description": "Enable interactive selection for ambiguous package matches", + "type": "boolean", + "default": false + }, "resume": { "description": "Enable support for some commands to resume", "type": "boolean", diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 25fdd391ff..cddcfbb440 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1402,7 +1402,8 @@ namespace AppInstaller::CLI::Workflow Logging::Telemetry().LogMultiAppMatch(); bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && - (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download); + (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download) && + Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::InteractivePackageSelection); std::optional selection; if (selectionSupported && !searchResult.Truncated) { diff --git a/src/AppInstallerCLITests/ExperimentalFeature.cpp b/src/AppInstallerCLITests/ExperimentalFeature.cpp index d14bbbf5ea..6c75f6040b 100644 --- a/src/AppInstallerCLITests/ExperimentalFeature.cpp +++ b/src/AppInstallerCLITests/ExperimentalFeature.cpp @@ -7,6 +7,8 @@ #include #include +#include +#include using namespace AppInstaller::Settings; using namespace TestCommon; @@ -72,4 +74,34 @@ TEST_CASE("ExperimentalFeature ExperimentalCmd", "[experimentalFeature]") REQUIRE_FALSE(ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::ExperimentalCmd, userSettingTest)); } +} + +TEST_CASE("ExperimentalFeature InteractivePackageSelection", "[experimentalFeature]") +{ + auto again = DeleteUserSettingsFiles(); + auto [json, enabled] = GENERATE( + std::make_pair(std::string_view{ "{}" }, false), + std::make_pair(std::string_view{ R"({ "experimentalFeatures": { "interactivePackageSelection": true } })" }, true), + std::make_pair(std::string_view{ R"({ "experimentalFeatures": { "interactivePackageSelection": false } })" }, false), + std::make_pair(std::string_view{ R"({ "experimentalFeatures": { "interactivePackageSelection": "string" } })" }, false)); + bool policyEnabled = GENERATE(false, true); + auto policiesKey = RegCreateVolatileTestRoot(); + SetRegistryValue(policiesKey.get(), ExperimentalFeaturesPolicyValueName, policyEnabled); + GroupPolicyTestOverride policies{ policiesKey.get() }; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + CAPTURE(json, policyEnabled); + REQUIRE(userSettingTest.Get() == enabled); + REQUIRE(ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::InteractivePackageSelection, userSettingTest) == + (enabled && policyEnabled)); + + auto feature = ExperimentalFeature::GetFeature(ExperimentalFeature::Feature::InteractivePackageSelection); + std::string_view jsonName = feature.JsonName(); + REQUIRE(jsonName == "interactivePackageSelection"); + auto features = ExperimentalFeature::GetAllFeatures(); + REQUIRE(std::any_of(features.begin(), features.end(), [](const auto& item) + { + return item.GetFeature() == ExperimentalFeature::Feature::InteractivePackageSelection; + })); } \ No newline at end of file diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 02d265825b..fb6ab17399 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -583,9 +583,57 @@ TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow REQUIRE(output.str().empty()); } +TEST_CASE("PackageSelection_FeatureDisabled", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + if (GENERATE(false, true)) + { + settings.Set(false); + } + std::istringstream input{ "0\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestQuery_ReturnTwo.Query); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + + SECTION("Install") + { + if (GENERATE(false, true)) + { + context.Args.AddArg(Execution::Args::Type::Silent); + } + context.Args.AddArg(Execution::Args::Type::Force); + InstallCommand({}).Execute(context); + } + SECTION("Show") + { + ShowCommand({}).Execute(context); + } + SECTION("Show versions") + { + context.Args.AddArg(Execution::Args::Type::ListVersions); + ShowCommand({}).Execute(context); + } + SECTION("Download") + { + DownloadCommand({}).Execute(context); + } + + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE_FALSE(context.Contains(Execution::Data::Manifest)); + REQUIRE(input.peek() == '0'); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); + REQUIRE(output.str().find(Resource::LocString{ Resource::String::PackageSelectionRefine }.get()) == std::string::npos); +} + TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") { TestUserSettings settings; + settings.Set(true); std::istringstream input{ "0\n" }; std::ostringstream output; TestContext context{ output, input }; @@ -628,6 +676,7 @@ TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") { TestUserSettings settings; + settings.Set(true); std::istringstream input{ "1\n" }; std::ostringstream output; TestContext context{ output, input }; @@ -677,6 +726,7 @@ TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][MultiQuery]") { TestUserSettings settings; + settings.Set(true); std::istringstream input{ "1\n" }; std::ostringstream output; TestContext context{ output, input }; @@ -702,6 +752,7 @@ TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][Mult TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][workflow]") { TestUserSettings settings; + settings.Set(true); auto width = GENERATE(size_t{20}, size_t{120}); TestHook::SetConsoleWidth_Override widthOverride{ std::optional{width} }; auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); @@ -825,6 +876,7 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") TestUserSettings settings; TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + settings.Set(true); bool differentName = GENERATE(false, true); bool differentId = GENERATE(false, true); bool missingMetadata = GENERATE(false, true); @@ -907,6 +959,7 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") { TestUserSettings settings; + settings.Set(true); std::istringstream input{ "2\n" }; std::ostringstream output; TestContext context{ output, input }; diff --git a/src/AppInstallerCommonCore/ExperimentalFeature.cpp b/src/AppInstallerCommonCore/ExperimentalFeature.cpp index c814afb965..b59905f0b9 100644 --- a/src/AppInstallerCommonCore/ExperimentalFeature.cpp +++ b/src/AppInstallerCommonCore/ExperimentalFeature.cpp @@ -65,6 +65,8 @@ namespace AppInstaller::Settings return userSettings.Get(); case ExperimentalFeature::Feature::Font: return userSettings.Get(); + case ExperimentalFeature::Feature::InteractivePackageSelection: + return userSettings.Get(); default: THROW_HR(E_UNEXPECTED); } @@ -98,6 +100,8 @@ namespace AppInstaller::Settings return ExperimentalFeature{ "Resume", "resume", "https://aka.ms/winget-settings", Feature::Resume }; case Feature::Font: return ExperimentalFeature{ "Font", "fonts", "https://aka.ms/winget-settings", Feature::Font }; + case Feature::InteractivePackageSelection: + return ExperimentalFeature{ "Interactive Package Selection", "interactivePackageSelection", "https://aka.ms/winget-settings", Feature::InteractivePackageSelection }; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h index 2dc097f548..436196cdc3 100644 --- a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h +++ b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h @@ -25,6 +25,7 @@ namespace AppInstaller::Settings DirectMSI = 0x1, Resume = 0x2, Font = 0x4, + InteractivePackageSelection = 0x8, Max, // This MUST always be after all experimental features // Features listed after Max will not be shown with the features command diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index f438d6ed8e..e000c6adda 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -102,6 +102,7 @@ namespace AppInstaller::Settings EFDirectMSI, EFResume, EFFonts, + EFInteractivePackageSelection, // Telemetry TelemetryDisable, // Install behavior @@ -194,6 +195,7 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFResume, bool, bool, false, ".experimentalFeatures.resume"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFFonts, bool, bool, false, ".experimentalFeatures.fonts"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFInteractivePackageSelection, bool, bool, false, ".experimentalFeatures.interactivePackageSelection"sv); // Telemetry SETTINGMAPPING_SPECIALIZATION(Setting::TelemetryDisable, bool, bool, false, ".telemetry.disable"sv); // Install behavior diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index 31ea8e46ae..5b6ee9cec7 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -317,6 +317,7 @@ namespace AppInstaller::Settings WINGET_VALIDATE_PASS_THROUGH(EFDirectMSI) WINGET_VALIDATE_PASS_THROUGH(EFResume) WINGET_VALIDATE_PASS_THROUGH(EFFonts) + WINGET_VALIDATE_PASS_THROUGH(EFInteractivePackageSelection) WINGET_VALIDATE_PASS_THROUGH(AnonymizePathForDisplay) WINGET_VALIDATE_PASS_THROUGH(TelemetryDisable) WINGET_VALIDATE_PASS_THROUGH(InteractivityDisable) From 64e144de1654fbe1d581c98ccef33e1bdf2448de Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:36:00 -0700 Subject: [PATCH 29/36] Avoid spelling false positives in prompt test inputs --- src/AppInstallerCLITests/PromptFlow.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index fb6ab17399..b96e3ea5cd 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -232,7 +232,7 @@ TEST_CASE("ReporterPromptForIntegerResponseWithinRange", "[PromptFlow]") const uint64_t maximum = minimum + GENERATE(uint64_t{0}, uint64_t{2}); const uint64_t response = GENERATE_COPY(minimum, maximum, minimum + (maximum - minimum) / 2); auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); - std::istringstream input{ std::to_string(response) + "\nnext\n" }; + std::istringstream input{ std::to_string(response) + "\n" "next\n" }; std::ostringstream output; Execution::Reporter reporter{ output, input }; reporter.SetConsoleStreamsForTest(true); @@ -252,7 +252,7 @@ TEST_CASE("ReporterPromptForIntegerResponseWithinRange_InvalidInput", "[PromptFl { auto response = GENERATE("wrong", "0", "1", "5", "18446744073709551615", "18446744073709551616"); auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); - std::istringstream input{ std::string{ response } + "\n3\nnext\n" }; + std::istringstream input{ std::string{ response } + "\n3\n" "next\n" }; std::ostringstream output; Execution::Reporter reporter{ output, input }; reporter.SetConsoleStreamsForTest(true); From 77cdf03e2c4a1dc06b48d1d0354ecad3723120cd Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:39:38 -0700 Subject: [PATCH 30/36] Fix x86 signedness warnings in selection tests --- src/AppInstallerCLITests/PromptFlow.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index b96e3ea5cd..5543fbad32 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -69,7 +69,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") context << PromptForSelection(table, Resource::String::PackageSelectionInstall, Resource::String::NumberedSelectionInvalid); REQUIRE_FALSE(context.IsTerminated()); - REQUIRE(context.Get() == 1); + REQUIRE(context.Get() == size_t{1}); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); @@ -94,7 +94,7 @@ TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") context << PromptForSelection(table, text("Choose a value"), text("Try again")); REQUIRE_FALSE(context.IsTerminated()); - REQUIRE(context.Get() == 1); + REQUIRE(context.Get() == size_t{1}); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); REQUIRE_FALSE(prompt.empty()); REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\n" + prompt + " Try again\n" + prompt + " "); From 24ca1a46449596bc0045b5274b65308546fc6b18 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:54:53 -0700 Subject: [PATCH 31/36] Use fixed feedback for numbered selection prompts --- .../Workflows/PromptFlow.cpp | 4 ++-- .../Workflows/PromptFlow.h | 5 ++--- .../Workflows/WorkflowBase.cpp | 2 +- src/AppInstallerCLITests/PromptFlow.cpp | 20 +++++++++---------- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 057d05b520..3dbaf8712d 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -419,8 +419,8 @@ namespace AppInstaller::CLI::Workflow const auto prompt = Resource::String::NumberedSelectionPrompt(count); AICLI_RETURN_IF_TERMINATED(context); - auto response = context.Reporter.PromptForIntegerResponseWithinRange(prompt, 0, count, Reporter::Level::Info, m_invalid, - [&]() { return context.IsTerminated(); }); + auto response = context.Reporter.PromptForIntegerResponseWithinRange(prompt, 0, count, Reporter::Level::Info, + Resource::String::NumberedSelectionInvalid, [&]() { return context.IsTerminated(); }); AICLI_RETURN_IF_TERMINATED(context); if (!response || *response == 0) { diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 6eecff5551..dcf6a3e93f 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -12,15 +12,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: SelectedIndex (zero-based index, or nullopt if prompting is unavailable) struct PromptForSelection : public WorkflowTask { - PromptForSelection(Execution::TableOutputBase& table, Resource::LocString title, Resource::LocString invalid) : - WorkflowTask("PromptForSelection"), m_table(table), m_title(std::move(title)), m_invalid(std::move(invalid)) {} + PromptForSelection(Execution::TableOutputBase& table, Resource::LocString title) : + WorkflowTask("PromptForSelection"), m_table(table), m_title(std::move(title)) {} void operator()(Execution::Context& context) const override; private: Execution::TableOutputBase& m_table; Resource::LocString m_title; - Resource::LocString m_invalid; }; // Handles all opened source(s) agreements if needed. diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index cddcfbb440..64425b60f1 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1467,7 +1467,7 @@ namespace AppInstaller::CLI::Workflow } } - context << PromptForSelection(table, title, Resource::String::NumberedSelectionInvalid); + context << PromptForSelection(table, title); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); } diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 5543fbad32..00e572b7a3 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -33,8 +33,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") table.OutputLine({ std::to_string(i) }); } - context << PromptForSelection(table, Resource::String::PackageSelectionInstall, - Resource::String::NumberedSelectionInvalid); + context << PromptForSelection(table, Resource::String::PackageSelectionInstall); auto selection = context.Get(); if (std::string_view{ response } == "0") { @@ -66,8 +65,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") } table.OutputLine({ "Second", "FirstSource" }); - context << PromptForSelection(table, Resource::String::PackageSelectionInstall, - Resource::String::NumberedSelectionInvalid); + context << PromptForSelection(table, Resource::String::PackageSelectionInstall); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == size_t{1}); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); @@ -75,7 +73,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); } -TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") +TEST_CASE("PromptFlow_Selection_CustomTitle", "[PromptFlow]") { TestUserSettings settings; std::istringstream input{ "wrong\n2\n" }; @@ -91,13 +89,15 @@ TEST_CASE("PromptFlow_Selection_CustomStrings", "[PromptFlow]") Execution::TableOutput<1> table(context.Reporter, { text("Choice") }); table.OutputLine({ "1 First" }); table.OutputLine({ "2 Second" }); - context << PromptForSelection(table, text("Choose a value"), text("Try again")); + context << PromptForSelection(table, text("Choose a value")); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == size_t{1}); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); + const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); REQUIRE_FALSE(prompt.empty()); - REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\n" + prompt + " Try again\n" + prompt + " "); + REQUIRE_FALSE(invalid.empty()); + REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\n" + prompt + " " + invalid + '\n' + prompt + " "); } TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") @@ -132,8 +132,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") context.Reporter.SetLevelMask(Execution::Reporter::Level::Info, false); } - context << PromptForSelection(table, Resource::String::PackageSelectionInstall, - Resource::String::NumberedSelectionInvalid); + context << PromptForSelection(table, Resource::String::PackageSelectionInstall); REQUIRE_FALSE(context.IsTerminated()); REQUIRE_FALSE(context.Get()); REQUIRE(output.str().empty()); @@ -157,8 +156,7 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") table.OutputContinuationLine({ "First" }); } } - PromptForSelection prompt(table, Resource::String::PackageSelectionInstall, - Resource::String::NumberedSelectionInvalid); + PromptForSelection prompt(table, Resource::String::PackageSelectionInstall); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); REQUIRE_FALSE(context.Get()); From 591f7bdcd51f3b8451a29e960d61a3fba9202b6c Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:49:33 -0700 Subject: [PATCH 32/36] Reuse ambiguity tables for numbered package selection --- .../#5345 - Interactive package selection.md | 24 +-- src/AppInstallerCLICore/TableOutput.cpp | 55 ++--- src/AppInstallerCLICore/TableOutput.h | 25 +-- .../Workflows/PromptFlow.cpp | 4 +- .../Workflows/PromptFlow.h | 2 +- .../Workflows/WorkflowBase.cpp | 99 ++------- .../Workflows/WorkflowBase.h | 15 +- src/AppInstallerCLITests/PromptFlow.cpp | 204 +++++++++++++++--- src/AppInstallerCLITests/TableOutput.cpp | 114 +++++----- 9 files changed, 289 insertions(+), 253 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index 80cf130614..df9a524d5e 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -56,30 +56,30 @@ Apart from the experimental setting, the prompt adds no command-line flags, grou ## UI/UX Design -Use the existing search table layout with a leading selection number. Always show the Source column, including when all candidates use the same source. For example: +Reuse the existing ambiguity table with a leading selection number. Show Name, Id, and Source, including Source when all candidates use the same source. For example: ```text Multiple packages match. Choose one to install. -# Name Id Version Source --------------------------------------------------- -1 Contoso Editor Contoso.Editor 2.4.0 winget -2 Contoso Editor Contoso.Editor.Pro 2.4.0 winget +# Name Id Source +------------------------------------------ +1 Contoso Editor Contoso.Editor winget +2 Contoso Editor Contoso.Editor.Pro winget Enter a number (1-2), or 0 to cancel: 1 Selected: Contoso Editor [Contoso.Editor] ``` -For candidates spanning sources: +For distinct candidates from different sources: ```text -# Name Id Version Source ------------------------------------------------ -1 Contoso Editor Contoso.Editor 2.4.0 winget -2 Contoso Editor Contoso.Editor 2.4.0 private +# Name Id Source +--------------------------------------- +1 Contoso Editor Contoso.Editor winget +2 Contoso Editor Contoso.Editor private ``` -Each source row always shows that source's package name, ID, and version. Additional sources within one candidate appear on continuation rows without another selection number. Use action-specific introductory text for viewing or downloading. Do not add another confirmation after selection. Existing consent prompts still apply. +Each candidate occupies one row, using the existing ambiguity report's package identity and source. Sources grouped within a candidate do not add rows. Use action-specific introductory text for viewing or downloading. Do not add another confirmation after selection. Existing consent prompts still apply. When prompting is unavailable, retain the candidate list and explain: @@ -87,7 +87,7 @@ When prompting is unavailable, retain the candidate list and explain: Specify a package with --id --exact --source . ``` -Displayed versions are source metadata, not a promise of installer applicability. The original version option remains authoritative. +The original version option remains authoritative. ## Capabilities diff --git a/src/AppInstallerCLICore/TableOutput.cpp b/src/AppInstallerCLICore/TableOutput.cpp index 8364ee2cda..5af774dde1 100644 --- a/src/AppInstallerCLICore/TableOutput.cpp +++ b/src/AppInstallerCLICore/TableOutput.cpp @@ -20,48 +20,24 @@ namespace AppInstaller::CLI::Execution void TableOutputBase::OutputLine(std::vector line) { THROW_HR_IF(E_INVALIDARG, line.size() != m_columns.size()); - m_buffer.push_back({ std::move(line) }); + m_buffer.emplace_back(std::move(line)); } - void TableOutputBase::OutputContinuationLine(std::vector line) - { - THROW_HR_IF(E_INVALIDARG, m_buffer.empty() || line.empty() || line.front() != m_buffer.back().Values.front()); - OutputLine(std::move(line)); - m_buffer.back().IsContinuation = true; - } - - void TableOutputBase::Complete() + void TableOutputBase::Complete(bool showLineNumbers) { if (!IsEmpty() && !m_bufferEvaluated) { - EvaluateAndFlushBuffer(); + EvaluateAndFlushBuffer(showLineNumbers); } } - size_t TableOutputBase::GetPrimaryRowCount() const - { - return static_cast(std::count_if(m_buffer.begin(), m_buffer.end(), [](const auto& row) - { - return !row.IsContinuation; - })); - } - - size_t TableOutputBase::GetNonEmptyRowCount(size_t column) const - { - THROW_HR_IF(E_INVALIDARG, column >= m_columns.size()); - return static_cast(std::count_if(m_buffer.begin(), m_buffer.end(), [column](const auto& row) - { - return !row.Values[column].empty(); - })); - } - - void TableOutputBase::EvaluateAndFlushBuffer() + void TableOutputBase::EvaluateAndFlushBuffer(bool showLineNumbers) { for (const auto& row : m_buffer) { for (size_t i = 0; i < m_columns.size(); ++i) { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(row.Values[i])); + m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(row[i])); } } @@ -83,7 +59,8 @@ namespace AppInstaller::CLI::Execution m_columns[i - 1].SpaceAfter = false; } - size_t totalRequired = 0; + m_lineNumberWidth = showLineNumbers ? std::to_string(m_buffer.size()).size() : 0; + size_t totalRequired = m_lineNumberWidth ? m_lineNumberWidth + 1 : 0; for (const auto& column : m_columns) { totalRequired += column.MaxLength + (column.SpaceAfter ? 1 : 0); @@ -97,10 +74,14 @@ namespace AppInstaller::CLI::Execution { auto widest = std::max_element(m_columns.begin(), m_columns.end(), [](const auto& left, const auto& right) { return left.MaxLength < right.MaxLength; }); + if (!widest->MaxLength) + { + break; + } --widest->MaxLength; + --totalRequired; --extra; } - totalRequired = *consoleWidth - 1; } std::vector header; @@ -110,22 +91,28 @@ namespace AppInstaller::CLI::Execution } OutputLineToStream(header); m_reporter.Info() << std::string(totalRequired, '-') << std::endl; + size_t lineNumber = 0; for (const auto& row : m_buffer) { - OutputLineToStream(row.Values, row.IsContinuation); + OutputLineToStream(row, ++lineNumber); } m_bufferEvaluated = true; } - void TableOutputBase::OutputLineToStream(const std::vector& line, bool isContinuation) + void TableOutputBase::OutputLineToStream(const std::vector& line, size_t lineNumber) { auto out = m_reporter.Info(); + if (m_lineNumberWidth) + { + const std::string number = lineNumber ? std::to_string(lineNumber) : "#"; + out << number << std::string(m_lineNumberWidth - number.size() + 1, ' '); + } for (size_t i = 0; i < m_columns.size(); ++i) { const auto& column = m_columns[i]; if (column.MaxLength) { - std::string_view value = isContinuation && i == 0 ? std::string_view{} : std::string_view{ line[i] }; + std::string_view value = line[i]; size_t valueLength = Utility::UTF8ColumnWidth(value); if (valueLength > column.MaxLength) { diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index 25283de5f4..c6439f0d42 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -18,20 +18,11 @@ namespace AppInstaller::CLI::Execution // Buffers rows until Complete() computes column widths and renders the table. void OutputLine(std::vector line); - // Requires the preceding row's first-column value; hides it only when rendered. - void OutputContinuationLine(std::vector line); - void Complete(); + void Complete(bool showLineNumbers = false); bool IsEmpty() const { return m_buffer.empty(); } - size_t GetPrimaryRowCount() const; - size_t GetNonEmptyRowCount(size_t column) const; + size_t GetRowCount() const { return m_buffer.size(); } private: - struct Row - { - std::vector Values; - bool IsContinuation = false; - }; - // A column in the table. struct Column { @@ -43,11 +34,12 @@ namespace AppInstaller::CLI::Execution Reporter& m_reporter; std::vector m_columns; - std::vector m_buffer; + std::vector> m_buffer; + size_t m_lineNumberWidth = 0; bool m_bufferEvaluated = false; - void EvaluateAndFlushBuffer(); - void OutputLineToStream(const std::vector& line, bool isContinuation = false); + void EvaluateAndFlushBuffer(bool showLineNumbers); + void OutputLineToStream(const std::vector& line, size_t lineNumber = 0); }; // Retains fixed-size headers and rows for existing table callers. @@ -64,10 +56,5 @@ namespace AppInstaller::CLI::Execution { TableOutputBase::OutputLine({ std::make_move_iterator(line.begin()), std::make_move_iterator(line.end()) }); } - - void OutputContinuationLine(line_t&& line) - { - TableOutputBase::OutputContinuationLine({ std::make_move_iterator(line.begin()), std::make_move_iterator(line.end()) }); - } }; } diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 3dbaf8712d..bf4712be47 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -399,7 +399,7 @@ namespace AppInstaller::CLI::Workflow { context.Add(std::optional{}); AICLI_RETURN_IF_TERMINATED(context); - const size_t count = m_table.GetPrimaryRowCount(); + const size_t count = m_table.GetRowCount(); THROW_HR_IF(E_INVALIDARG, !count); if (!IsInteractivityAllowed(context)) @@ -414,7 +414,7 @@ namespace AppInstaller::CLI::Workflow auto out = context.Reporter.Info(); out << m_title << std::endl << std::endl; - m_table.Complete(); + m_table.Complete(true); out << std::endl; const auto prompt = Resource::String::NumberedSelectionPrompt(count); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index dcf6a3e93f..97652fd54d 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -6,7 +6,7 @@ namespace AppInstaller::CLI::Workflow { - // Prompts for a numbered choice among the table's primary rows. + // Prompts for a numbered choice among the table's rows. // Required Args: None // Inputs: None // Outputs: SelectedIndex (zero-based index, or nullopt if prompting is unavailable) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 64425b60f1..0d14d56c27 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1043,7 +1043,12 @@ namespace AppInstaller::CLI::Workflow else { context.Reporter.Info() << std::endl << Resource::String::SearchFailureErrorListMatches << std::endl; - context << ReportMultiplePackageFoundResultWithSource; + auto table = GetMultiplePackageFoundResultTableWithSource(context); + table.Complete(); + if (searchResult.Truncated) + { + context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } } } @@ -1052,7 +1057,7 @@ namespace AppInstaller::CLI::Workflow } } - void ReportMultiplePackageFoundResult(Execution::Context& context) + Execution::TableOutputBase GetMultiplePackageFoundResultTable(Execution::Context& context) { auto& searchResult = context.Get(); @@ -1072,15 +1077,10 @@ namespace AppInstaller::CLI::Workflow }); } - table.Complete(); - - if (searchResult.Truncated) - { - context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; - } + return table; } - void ReportMultiplePackageFoundResultWithSource(Execution::Context& context) + Execution::TableOutputBase GetMultiplePackageFoundResultTableWithSource(Execution::Context& context) { auto& searchResult = context.Get(); @@ -1113,12 +1113,7 @@ namespace AppInstaller::CLI::Workflow }); } - table.Complete(); - - if (searchResult.Truncated) - { - context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; - } + return table; } void ReportListResult::operator()(Execution::Context& context) const @@ -1401,6 +1396,8 @@ namespace AppInstaller::CLI::Workflow { Logging::Telemetry().LogMultiAppMatch(); + auto table = operationTargetsInstalled ? GetMultiplePackageFoundResultTable(context) : + GetMultiplePackageFoundResultTableWithSource(context); bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download) && Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::InteractivePackageSelection); @@ -1410,63 +1407,6 @@ namespace AppInstaller::CLI::Workflow auto title = m_operationType == OperationType::Install ? Resource::String::PackageSelectionInstall : m_operationType == OperationType::Download ? Resource::String::PackageSelectionDownload : Resource::String::PackageSelectionShow; - Execution::TableOutput<5> table(context.Reporter, - { - Resource::LocString{ Utility::LocIndString{ "#"sv } }, - Resource::String::SearchName, - Resource::String::SearchId, - Resource::String::SearchVersion, - Resource::String::SearchSource - }); - const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); - for (size_t i = 0; i < searchResult.Matches.size(); ++i) - { - auto package = searchResult.Matches[i].Package; - auto availablePackages = package->GetAvailable(); - if (availablePackages.empty()) - { - table.OutputLine({ - std::to_string(i + 1), - package->GetProperty(PackageProperty::Name), - package->GetProperty(PackageProperty::Id), - unavailable, - unavailable - }); - } - bool firstSource = true; - for (const auto& available : availablePackages) - { - Execution::TableOutput<5>::line_t line{ - std::to_string(i + 1), - available->GetProperty(PackageProperty::Name), - available->GetProperty(PackageProperty::Id), - unavailable, - unavailable - }; - auto version = available->GetLatestVersion(); - auto source = available->GetSource(); - std::string versionString = version ? version->GetProperty(PackageVersionProperty::Version).get() : std::string{}; - std::string sourceName = source ? source.GetDetails().Name : std::string{}; - if (!versionString.empty()) - { - line[3] = std::move(versionString); - } - if (!sourceName.empty()) - { - line[4] = std::move(sourceName); - } - if (firstSource) - { - table.OutputLine(std::move(line)); - } - else - { - table.OutputContinuationLine(std::move(line)); - } - firstSource = false; - } - } - context << PromptForSelection(table, title); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); @@ -1479,16 +1419,15 @@ namespace AppInstaller::CLI::Workflow context.Reporter.Info() << Resource::String::PackageSelectionSelected(package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id)) << std::endl; } - else if (operationTargetsInstalled) - { - context.Reporter.Warn() << Resource::String::MultipleInstalledPackagesFound << std::endl; - context << ReportMultiplePackageFoundResult; - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); - } else { - context.Reporter.Warn() << Resource::String::MultiplePackagesFound << std::endl; - context << ReportMultiplePackageFoundResultWithSource; + context.Reporter.Warn() << (operationTargetsInstalled ? Resource::String::MultipleInstalledPackagesFound : + Resource::String::MultiplePackagesFound) << std::endl; + table.Complete(); + if (searchResult.Truncated) + { + context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } if (selectionSupported) { context.Reporter.Info() << Resource::String::PackageSelectionRefine << std::endl; diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index e0a74bf441..1273a8af9c 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -15,6 +15,7 @@ namespace AppInstaller::CLI::Execution { struct Context; + struct TableOutputBase; } namespace AppInstaller::CLI::Workflow @@ -236,17 +237,11 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void HandleSearchResultFailures(Execution::Context& context); - // Outputs the search results when multiple packages found but only one expected. - // Required Args: None - // Inputs: SearchResult - // Outputs: None - void ReportMultiplePackageFoundResult(Execution::Context& context); + // Builds the ambiguity table for installed-package matches. + Execution::TableOutputBase GetMultiplePackageFoundResultTable(Execution::Context& context); - // Outputs the search results when multiple packages found but only one expected. - // Required Args: None - // Inputs: SearchResult - // Outputs: None - void ReportMultiplePackageFoundResultWithSource(Execution::Context& context); + // Builds the ambiguity table for available-package matches. + Execution::TableOutputBase GetMultiplePackageFoundResultTableWithSource(Execution::Context& context); // Ensures that there is at least one result in the search. // Required Args: bool indicating if the search result is from installed source diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 00e572b7a3..d70de5340f 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -58,11 +58,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") TestContext context{ output, input }; context.Reporter.SetConsoleStreamsForTest(true); Execution::TableOutput<2> table(context.Reporter, { Resource::String::SearchName, Resource::String::SearchSource }); - table.OutputLine({ "First", "FirstSource" }); - if (GENERATE(false, true)) - { - table.OutputContinuationLine({ "First", "SecondSource" }); - } + table.OutputLine({ GENERATE("First", ""), "FirstSource" }); table.OutputLine({ "Second", "FirstSource" }); context << PromptForSelection(table, Resource::String::PackageSelectionInstall); @@ -87,8 +83,8 @@ TEST_CASE("PromptFlow_Selection_CustomTitle", "[PromptFlow]") return Resource::LocString{ AppInstaller::Utility::LocIndString{ std::move(value) } }; }; Execution::TableOutput<1> table(context.Reporter, { text("Choice") }); - table.OutputLine({ "1 First" }); - table.OutputLine({ "2 Second" }); + table.OutputLine({ "First" }); + table.OutputLine({ "Second" }); context << PromptForSelection(table, text("Choose a value")); REQUIRE_FALSE(context.IsTerminated()); @@ -97,7 +93,7 @@ TEST_CASE("PromptFlow_Selection_CustomTitle", "[PromptFlow]") const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); REQUIRE_FALSE(prompt.empty()); REQUIRE_FALSE(invalid.empty()); - REQUIRE(output.str() == "Choose a value\n\nChoice\n--------\n1 First\n2 Second\n\n" + prompt + " " + invalid + '\n' + prompt + " "); + REQUIRE(output.str() == "Choose a value\n\n# Choice\n--------\n1 First\n2 Second\n\n" + prompt + " " + invalid + '\n' + prompt + " "); } TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") @@ -147,14 +143,10 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") TestContext context{ output, input }; context.Reporter.SetConsoleStreamsForTest(true); Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); - auto count = GENERATE(size_t{0}, size_t{1}); - if (count) + auto count = GENERATE(size_t{0}, size_t{1}, size_t{2}); + for (size_t i = 0; i < count; ++i) { table.OutputLine({ "First" }); - if (GENERATE(false, true)) - { - table.OutputContinuationLine({ "First" }); - } } PromptForSelection prompt(table, Resource::String::PackageSelectionInstall); @@ -769,7 +761,6 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w auto expectedPackage = result.Matches[1].Package; bool expectPrompt = true; bool expectSecondSource = true; - size_t expectedRows = 2; SECTION("Same identity across sources") { @@ -785,7 +776,6 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w auto package = TestCompositePackage::Make(versions, firstSource); package->Available.emplace_back(TestPackage::Make(versions, secondSource)); result.Matches[0].Package = package; - expectedRows = 3; } SECTION("Unique source priority") { @@ -836,12 +826,13 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w REQUIRE(AppInstaller::Utility::UTF8ColumnWidth(line) < width); ++lineCount; } - REQUIRE(lineCount == expectedRows + 2); + REQUIRE(lineCount == size_t{4}); if (width == 120) { REQUIRE(tableText.find(manifest.DefaultLocalization.Get()) != std::string::npos); REQUIRE(tableText.find(manifest.Id) != std::string::npos); - REQUIRE(tableText.find(manifest.Version) != std::string::npos); + REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchVersion }.get()) == std::string::npos); + REQUIRE(tableText.find(manifest.Version) == std::string::npos); REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos); REQUIRE(tableText.find("FirstSource") != std::string::npos); REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSecondSource); @@ -859,7 +850,7 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); } -TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") +TEST_CASE("PackageSelection_CandidateRowIdentity", "[PackageSelection][workflow]") { struct PrimaryPackage : TestCompositePackage { @@ -877,7 +868,7 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") settings.Set(true); bool differentName = GENERATE(false, true); bool differentId = GENERATE(false, true); - bool missingMetadata = GENERATE(false, true); + bool missingSourceName = GENERATE(false, true); auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); manifest.Id = "Public.App"; manifest.Version = "1.0"; @@ -894,13 +885,10 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") manifest.Version = "2.0"; manifest.DefaultLocalization.Add(secondName); package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); - manifest.Version = std::string{ missingMetadata ? "" : "3.0" }; - auto thirdPackage = TestPackage::Make(std::vector{ manifest }, secondSource); - if (missingMetadata) + if (missingSourceName) { - thirdPackage->Source.reset(); + firstSource->Details.Name.clear(); } - package->Available.emplace_back(std::move(thirdPackage)); SearchResult result; result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, secondId }); @@ -930,13 +918,15 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") std::string line; REQUIRE(static_cast(std::getline(tableStream, line))); REQUIRE(static_cast(std::getline(tableStream, line))); - const std::string unavailable = Resource::LocString{ Resource::String::Unavailable }.get(); std::vector> expectedRows{ - { "1", "PublicName", "Public.App", "1.0", "FirstSource" }, - { secondName, secondId, "2.0", "SecondSource" }, - { secondName, secondId, missingMetadata ? unavailable : "3.0", missingMetadata ? unavailable : "SecondSource" }, - { "2", "OtherName", "Other.App", "4.0", "FirstSource" } + { "1", secondName, secondId }, + { "2", "OtherName", "Other.App" } }; + if (!missingSourceName) + { + expectedRows[0].emplace_back("FirstSource"); + expectedRows[1].emplace_back("FirstSource"); + } for (const auto& expectedRow : expectedRows) { REQUIRE(static_cast(std::getline(tableStream, line))); @@ -954,6 +944,160 @@ TEST_CASE("PackageSelection_SourceRowIdentity", "[PackageSelection][workflow]") REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); } +TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + bool withSource = GENERATE(false, true); + bool available = GENERATE(false, true); + bool prompt = GENERATE(false, true); + auto source = std::make_shared(); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + std::istringstream input{ "2\n" }; + std::ostringstream output; + std::ostringstream expectedOutput; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + Execution::Reporter expectedReporter{ expectedOutput, input }; + expectedReporter.SetStyle(VisualStyle::NoVT); + std::vector header{ Resource::String::SearchName, Resource::String::SearchId }; + if (withSource) + { + header.emplace_back(Resource::String::SearchSource); + } + if (prompt) + { + header.insert(header.begin(), Resource::LocString{ AppInstaller::Utility::LocIndString{ "#"sv } }); + expectedReporter.Info() << Resource::String::PackageSelectionShow << std::endl << std::endl; + } + Execution::TableOutputBase expectedTable{ expectedReporter, std::move(header) }; + SearchResult result; + for (size_t i = 1; i <= 2; ++i) + { + auto name = "Package" + std::to_string(i); + manifest.Id = "Test." + name; + manifest.DefaultLocalization.Add(name); + auto package = available ? TestCompositePackage::Make(std::vector{ manifest }, source) : + TestCompositePackage::Make(manifest, TestPackage::MetadataMap{}); + result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + std::vector line{ name, manifest.Id }; + if (withSource) + { + line.emplace_back(available ? source->Details.Name : ""); + } + if (prompt) + { + line.insert(line.begin(), std::to_string(i)); + } + expectedTable.OutputLine(std::move(line)); + } + context.Add(std::move(result)); + auto table = withSource ? GetMultiplePackageFoundResultTableWithSource(context) : GetMultiplePackageFoundResultTable(context); + REQUIRE(table.GetRowCount() == size_t{2}); + REQUIRE(output.str().empty()); + expectedTable.Complete(); + if (prompt) + { + context << PromptForSelection(table, Resource::String::PackageSelectionShow); + expectedReporter.Info() << std::endl << Resource::String::NumberedSelectionPrompt(2) << ' '; + REQUIRE(context.Get() == size_t{1}); + } + else + { + table.Complete(); + REQUIRE(input.peek() == '2'); + } + + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(output.str() == expectedOutput.str()); + REQUIRE(source->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_AmbiguityOutputUnchanged", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + bool enabled = GENERATE(false, true); + settings.Set(bool{ enabled }); + bool installed = GENERATE(false, true); + bool truncated = GENERATE(false, true); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + result.Truncated = truncated; + std::istringstream input{ "2\n" }; + std::ostringstream output; + std::ostringstream expectedOutput; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + context.Add(result); + TestContext expectedContext{ expectedOutput, input }; + expectedContext.Reporter.SetStyle(VisualStyle::NoVT); + expectedContext.Add(std::move(result)); + expectedContext.Reporter.Warn() << (installed ? Resource::String::MultipleInstalledPackagesFound : Resource::String::MultiplePackagesFound) << std::endl; + auto table = installed ? GetMultiplePackageFoundResultTable(expectedContext) : GetMultiplePackageFoundResultTableWithSource(expectedContext); + table.Complete(); + if (truncated) + { + expectedContext.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } + if (enabled && !installed) + { + expectedContext.Reporter.Info() << Resource::String::PackageSelectionRefine << std::endl; + } + + context << EnsureOneMatchFromSearchResult(installed ? OperationType::Uninstall : OperationType::Install, PackageSelectionBehavior::Prompt); + + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str() == expectedOutput.str()); +} + +TEST_CASE("PackageSelection_PartialSearchFailureDoesNotPrompt", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + result.Truncated = GENERATE(false, true); + result.Failures.push_back({ "BrokenSource", std::make_exception_ptr(wil::ResultException(E_FAIL)) }); + std::istringstream input{ "2\n" }; + std::ostringstream output; + std::ostringstream expectedOutput; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.SetFlags(Execution::ContextFlag::ShowSearchResultsOnPartialFailure); + context.Add(result); + TestContext expectedContext{ expectedOutput, input }; + expectedContext.Reporter.SetStyle(VisualStyle::NoVT); + expectedContext.Add(std::move(result)); + expectedContext.Reporter.Info() << std::endl << Resource::String::SearchFailureErrorListMatches << std::endl; + auto table = GetMultiplePackageFoundResultTableWithSource(expectedContext); + table.Complete(); + if (expectedContext.Get().Truncated) + { + expectedContext.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } + + context << HandleSearchResultFailures; + + REQUIRE(context.GetTerminationHR() == E_FAIL); + REQUIRE_FALSE(context.Contains(Execution::Data::SelectedIndex)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); + auto tableStart = output.str().find(expectedOutput.str()); + REQUIRE(tableStart != std::string::npos); + REQUIRE(output.str().substr(tableStart) == expectedOutput.str()); +} + TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") { TestUserSettings settings; diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index e603552519..cbe1b0b1e1 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -33,18 +33,16 @@ TEST_CASE("TableOutput_DynamicMatchesTyped", "[tableoutput]") TableOutputBase dynamic(dynamicReporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Id") }); typed.OutputLine({ "LongPackageName", "", "test.id" }); dynamic.OutputLine({ "LongPackageName", "", "test.id" }); - if (GENERATE(false, true)) - { - typed.OutputContinuationLine({ "LongPackageName", "", "other.id" }); - dynamic.OutputContinuationLine({ "LongPackageName", "", "other.id" }); - } - typed.Complete(); - dynamic.Complete(); + typed.OutputLine({ "OtherPackageName", "", "other.id" }); + dynamic.OutputLine({ "OtherPackageName", "", "other.id" }); + bool showLineNumbers = GENERATE(false, true); + typed.Complete(showLineNumbers); + dynamic.Complete(showLineNumbers); REQUIRE_FALSE(dynamic.IsEmpty()); - REQUIRE(dynamic.GetPrimaryRowCount() == size_t{1}); + REQUIRE(dynamic.GetRowCount() == size_t{2}); REQUIRE(dynamicOutput.str() == typedOutput.str()); - dynamic.Complete(); + dynamic.Complete(showLineNumbers); REQUIRE(dynamicOutput.str() == typedOutput.str()); } @@ -60,34 +58,27 @@ TEST_CASE("TableOutput_DynamicInvalidDimensions", "[tableoutput]") REQUIRE(output.str().empty()); } -TEST_CASE("TableOutput_NonEmptyRowCount", "[tableoutput]") +TEST_CASE("TableOutput_RowCount", "[tableoutput]") { std::ostringstream output; std::istringstream input; TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; Reporter reporter(output, input); TableOutput<2> table(reporter, { MakeHeader("Choice"), MakeHeader("Source") }); - REQUIRE(table.GetPrimaryRowCount() == size_t{0}); - REQUIRE(table.GetNonEmptyRowCount(0) == size_t{0}); - REQUIRE(table.GetNonEmptyRowCount(1) == size_t{0}); - REQUIRE_THROWS_HR(table.GetNonEmptyRowCount(2), E_INVALIDARG); + REQUIRE(table.GetRowCount() == size_t{0}); table.OutputLine({ "1", "FirstSource" }); table.OutputLine({ "", "SecondSource" }); table.OutputLine({ "2", "ThirdSource" }); table.OutputLine({ "", "" }); - REQUIRE(table.GetPrimaryRowCount() == size_t{4}); - REQUIRE(table.GetNonEmptyRowCount(0) == size_t{2}); - REQUIRE(table.GetNonEmptyRowCount(1) == size_t{3}); + REQUIRE(table.GetRowCount() == size_t{4}); REQUIRE(output.str().empty()); - table.Complete(); - REQUIRE(table.GetPrimaryRowCount() == size_t{4}); - REQUIRE(table.GetNonEmptyRowCount(0) == size_t{2}); - REQUIRE(table.GetNonEmptyRowCount(1) == size_t{3}); + table.Complete(GENERATE(false, true)); + REQUIRE(table.GetRowCount() == size_t{4}); } -TEST_CASE("TableOutput_ContinuationRows", "[tableoutput]") +TEST_CASE("TableOutput_LineNumbers", "[tableoutput]") { std::ostringstream output; std::ostringstream expectedOutput; @@ -97,62 +88,55 @@ TEST_CASE("TableOutput_ContinuationRows", "[tableoutput]") Reporter expectedReporter(expectedOutput, input); reporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); expectedReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); - TableOutput<3> table(reporter, { MakeHeader("#"), MakeHeader("Name"), MakeHeader("Source") }); - TableOutput<3> expected(expectedReporter, { MakeHeader("#"), MakeHeader("Name"), MakeHeader("Source") }); - - table.OutputLine({ "1", "FirstName", "FirstSource" }); - table.OutputContinuationLine({ "1", "FirstName", "SecondSource" }); - table.OutputContinuationLine({ "1", "AliasName", "ThirdSource" }); - table.OutputLine({ "2", "SecondName", "FirstSource" }); - table.OutputContinuationLine({ "2", "SecondName", "SecondSource" }); - expected.OutputLine({ "1", "FirstName", "FirstSource" }); - expected.OutputLine({ "", "FirstName", "SecondSource" }); - expected.OutputLine({ "", "AliasName", "ThirdSource" }); - expected.OutputLine({ "2", "SecondName", "FirstSource" }); - expected.OutputLine({ "", "SecondName", "SecondSource" }); - - REQUIRE(table.GetPrimaryRowCount() == size_t{2}); - REQUIRE(table.GetNonEmptyRowCount(0) == size_t{5}); + TableOutput<3> table(reporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Source") }); + TableOutput<4> expected(expectedReporter, { MakeHeader("#"), MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Source") }); + size_t count = GENERATE(size_t{1}, size_t{9}, size_t{10}, size_t{99}, size_t{100}); + for (size_t i = 1; i <= count; ++i) + { + auto number = std::to_string(i); + table.OutputLine({ "Package" + number, "", "Source" }); + expected.OutputLine({ number, "Package" + number, "", "Source" }); + } + + REQUIRE(table.GetRowCount() == count); REQUIRE(output.str().empty()); - table.Complete(); + table.Complete(true); expected.Complete(); REQUIRE(output.str() == expectedOutput.str()); - REQUIRE(table.GetPrimaryRowCount() == size_t{2}); - REQUIRE(table.GetNonEmptyRowCount(0) == size_t{5}); - table.Complete(); + REQUIRE(table.GetRowCount() == count); + table.Complete(true); REQUIRE(output.str() == expectedOutput.str()); } -TEST_CASE("TableOutput_InvalidContinuation", "[tableoutput]") +TEST_CASE("TableOutput_LineNumbersNotTruncated", "[tableoutput]") { std::ostringstream output; std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{GENERATE(size_t{1}, size_t{4}, size_t{8})} }; Reporter reporter(output, input); - TableOutputBase table(reporter, { MakeHeader("Choice"), MakeHeader("Source") }); - size_t expectedRows = 0; + reporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Source") }); + size_t count = GENERATE(size_t{10}, size_t{100}); - SECTION("Without a primary row") + for (size_t i = 0; i < count; ++i) { - REQUIRE_THROWS_HR(table.OutputContinuationLine({ "1", "FirstSource" }), E_INVALIDARG); + table.OutputLine({ "Package", "Source" }); } - SECTION("After a primary row") + table.Complete(true); + + std::istringstream lines{ output.str() }; + std::string line; + REQUIRE(static_cast(std::getline(lines, line))); + REQUIRE(line.find('#') == 0); + REQUIRE(static_cast(std::getline(lines, line))); + for (size_t i = 1; i <= count; ++i) { - table.OutputLine({ "1", "FirstSource" }); - expectedRows = 1; - SECTION("Invalid dimensions") - { - auto columnCount = GENERATE(size_t{0}, size_t{1}, size_t{3}); - REQUIRE_THROWS_HR(table.OutputContinuationLine(std::vector(columnCount, "1")), E_INVALIDARG); - } - SECTION("Different first-column value") - { - REQUIRE_THROWS_HR(table.OutputContinuationLine({ "2", "SecondSource" }), E_INVALIDARG); - } + REQUIRE(static_cast(std::getline(lines, line))); + auto number = std::to_string(i); + REQUIRE(line.find(number + std::string(std::to_string(count).size() - number.size() + 1, ' ')) == 0); } - - REQUIRE(table.GetPrimaryRowCount() == expectedRows); - REQUIRE(table.GetNonEmptyRowCount(0) == expectedRows); - REQUIRE(output.str().empty()); + REQUIRE_FALSE(std::getline(lines, line)); + REQUIRE(table.GetRowCount() == count); } // Test that all rows are buffered and column widths account for values beyond the first 50 rows. @@ -243,7 +227,7 @@ TEST_CASE("TableOutput_Empty_ProducesNoOutput", "[tableoutput]") TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); REQUIRE(table.IsEmpty()); - table.Complete(); + table.Complete(GENERATE(false, true)); REQUIRE(output.str().empty()); } @@ -335,7 +319,7 @@ TEST_CASE("TableOutput_ManyRowsBuffered", "[tableoutput]") }); } - table.Complete(); + table.Complete(GENERATE(false, true)); REQUIRE_FALSE(table.IsEmpty()); From d729b8ba4376b3588e5a04c9cc72df404094cc03 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:00:32 -0700 Subject: [PATCH 33/36] Use a shared title for interactive package selection --- .../#5345 - Interactive package selection.md | 4 ++-- src/AppInstallerCLICore/Resources.h | 4 +--- .../Workflows/WorkflowBase.cpp | 5 +---- .../Shared/Strings/en-us/winget.resw | 14 +++----------- src/AppInstallerCLITests/PromptFlow.cpp | 15 +++++++++------ 5 files changed, 16 insertions(+), 26 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index df9a524d5e..498777ed29 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -59,7 +59,7 @@ Apart from the experimental setting, the prompt adds no command-line flags, grou Reuse the existing ambiguity table with a leading selection number. Show Name, Id, and Source, including Source when all candidates use the same source. For example: ```text -Multiple packages match. Choose one to install. +Multiple packages match. Choose one. # Name Id Source ------------------------------------------ @@ -79,7 +79,7 @@ For distinct candidates from different sources: 2 Contoso Editor Contoso.Editor private ``` -Each candidate occupies one row, using the existing ambiguity report's package identity and source. Sources grouped within a candidate do not add rows. Use action-specific introductory text for viewing or downloading. Do not add another confirmation after selection. Existing consent prompts still apply. +Each candidate occupies one row, using the existing ambiguity report's package identity and source. Sources grouped within a candidate do not add rows. Use the same introductory text for every command. Do not add another confirmation after selection. Existing consent prompts still apply. When prompting is unavailable, retain the candidate list and explain: diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 5f6434828e..d689e7fb48 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -534,11 +534,9 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PackageDependencies); WINGET_DEFINE_RESOURCE_STRINGID(PackageIsPinned); WINGET_DEFINE_RESOURCE_STRINGID(PackageRequiresDependencies); - WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionDownload); - WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionInstall); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefine); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionSelected); - WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionShow); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionTitle); WINGET_DEFINE_RESOURCE_STRINGID(PendingWorkError); WINGET_DEFINE_RESOURCE_STRINGID(PinAddBlockingArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(PinAddCommandLongDescription); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 0d14d56c27..4339f70566 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1404,10 +1404,7 @@ namespace AppInstaller::CLI::Workflow std::optional selection; if (selectionSupported && !searchResult.Truncated) { - auto title = m_operationType == OperationType::Install ? Resource::String::PackageSelectionInstall : - m_operationType == OperationType::Download ? Resource::String::PackageSelectionDownload : Resource::String::PackageSelectionShow; - - context << PromptForSelection(table, title); + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); AICLI_RETURN_IF_TERMINATED(context); selection = context.Get(); } diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index fe2b5f8ffe..fb95c0ac80 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -407,17 +407,9 @@ They can be configured through the settings file 'winget settings'. Multiple packages found matching input criteria. Please refine the input. - - Multiple packages match. Choose one to install. - Introduces a numbered list of packages. The user must select a package before installation can continue. - - - Multiple packages match. Choose one to download. - Introduces a numbered list of packages to download, not install. - - - Multiple packages match. Choose one to view. - Introduces a numbered list of packages whose information can be displayed. + + Multiple packages match. Choose one. + Introduces a numbered list of packages the user can choose from. Enter a number (1-{0}), or 0 to cancel: diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index d70de5340f..6b12101b88 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -33,7 +33,7 @@ TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") table.OutputLine({ std::to_string(i) }); } - context << PromptForSelection(table, Resource::String::PackageSelectionInstall); + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); auto selection = context.Get(); if (std::string_view{ response } == "0") { @@ -61,7 +61,7 @@ TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") table.OutputLine({ GENERATE("First", ""), "FirstSource" }); table.OutputLine({ "Second", "FirstSource" }); - context << PromptForSelection(table, Resource::String::PackageSelectionInstall); + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == size_t{1}); const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); @@ -128,7 +128,7 @@ TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") context.Reporter.SetLevelMask(Execution::Reporter::Level::Info, false); } - context << PromptForSelection(table, Resource::String::PackageSelectionInstall); + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); REQUIRE_FALSE(context.IsTerminated()); REQUIRE_FALSE(context.Get()); REQUIRE(output.str().empty()); @@ -148,7 +148,7 @@ TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") { table.OutputLine({ "First" }); } - PromptForSelection prompt(table, Resource::String::PackageSelectionInstall); + PromptForSelection prompt(table, Resource::String::PackageSelectionTitle); REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); REQUIRE_FALSE(context.Get()); @@ -808,6 +808,9 @@ TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][w INFO(output.str()); REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == expectedPackage); + const std::string title = Resource::LocString{ Resource::String::PackageSelectionTitle }.get(); + REQUIRE_FALSE(title.empty()); + REQUIRE((output.str().find(title + "\n\n") != std::string::npos) == expectPrompt); REQUIRE((output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); if (expectPrompt) { @@ -970,7 +973,7 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow if (prompt) { header.insert(header.begin(), Resource::LocString{ AppInstaller::Utility::LocIndString{ "#"sv } }); - expectedReporter.Info() << Resource::String::PackageSelectionShow << std::endl << std::endl; + expectedReporter.Info() << Resource::String::PackageSelectionTitle << std::endl << std::endl; } Execution::TableOutputBase expectedTable{ expectedReporter, std::move(header) }; SearchResult result; @@ -1000,7 +1003,7 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow expectedTable.Complete(); if (prompt) { - context << PromptForSelection(table, Resource::String::PackageSelectionShow); + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); expectedReporter.Info() << std::endl << Resource::String::NumberedSelectionPrompt(2) << ' '; REQUIRE(context.Get() == size_t{1}); } From 4ee3425cc722468630d957e813a3e55e2a2aea5a Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:52:43 -0700 Subject: [PATCH 34/36] Make ambiguity table builders private to the workflow --- .../Workflows/WorkflowBase.cpp | 118 +++++++++--------- .../Workflows/WorkflowBase.h | 7 -- src/AppInstallerCLITests/PromptFlow.cpp | 75 +++++++---- 3 files changed, 108 insertions(+), 92 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 4339f70566..0bcaf306e9 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -48,6 +48,65 @@ namespace AppInstaller::CLI::Workflow } } + Execution::TableOutputBase GetMultiplePackageFoundResultTable(Execution::Context& context) + { + auto& searchResult = context.Get(); + + Execution::TableOutput<2> table(context.Reporter, + { + Resource::String::SearchName, + Resource::String::SearchId + }); + + for (size_t i = 0; i < searchResult.Matches.size(); ++i) + { + auto package = searchResult.Matches[i].Package; + + table.OutputLine({ + package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id) + }); + } + + return table; + } + + Execution::TableOutputBase GetMultiplePackageFoundResultTableWithSource(Execution::Context& context) + { + auto& searchResult = context.Get(); + + Execution::TableOutput<3> table(context.Reporter, + { + Resource::String::SearchName, + Resource::String::SearchId, + Resource::String::SearchSource + }); + + for (size_t i = 0; i < searchResult.Matches.size(); ++i) + { + auto package = searchResult.Matches[i].Package; + + std::string sourceName; + auto available = package->GetAvailable(); + if (!available.empty()) + { + auto source = available[0]->GetSource(); + if (source) + { + sourceName = source.GetDetails().Name; + } + } + + table.OutputLine({ + package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id), + std::move(sourceName) + }); + } + + return table; + } + void ReportIdentity( Execution::Context& context, Utility::LocIndView prefix, @@ -1057,65 +1116,6 @@ namespace AppInstaller::CLI::Workflow } } - Execution::TableOutputBase GetMultiplePackageFoundResultTable(Execution::Context& context) - { - auto& searchResult = context.Get(); - - Execution::TableOutput<2> table(context.Reporter, - { - Resource::String::SearchName, - Resource::String::SearchId - }); - - for (size_t i = 0; i < searchResult.Matches.size(); ++i) - { - auto package = searchResult.Matches[i].Package; - - table.OutputLine({ - package->GetProperty(PackageProperty::Name), - package->GetProperty(PackageProperty::Id) - }); - } - - return table; - } - - Execution::TableOutputBase GetMultiplePackageFoundResultTableWithSource(Execution::Context& context) - { - auto& searchResult = context.Get(); - - Execution::TableOutput<3> table(context.Reporter, - { - Resource::String::SearchName, - Resource::String::SearchId, - Resource::String::SearchSource - }); - - for (size_t i = 0; i < searchResult.Matches.size(); ++i) - { - auto package = searchResult.Matches[i].Package; - - std::string sourceName; - auto available = package->GetAvailable(); - if (!available.empty()) - { - auto source = available[0]->GetSource(); - if (source) - { - sourceName = source.GetDetails().Name; - } - } - - table.OutputLine({ - package->GetProperty(PackageProperty::Name), - package->GetProperty(PackageProperty::Id), - std::move(sourceName) - }); - } - - return table; - } - void ReportListResult::operator()(Execution::Context& context) const { auto& searchResult = context.Get(); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 1273a8af9c..3c026e0310 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -15,7 +15,6 @@ namespace AppInstaller::CLI::Execution { struct Context; - struct TableOutputBase; } namespace AppInstaller::CLI::Workflow @@ -237,12 +236,6 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void HandleSearchResultFailures(Execution::Context& context); - // Builds the ambiguity table for installed-package matches. - Execution::TableOutputBase GetMultiplePackageFoundResultTable(Execution::Context& context); - - // Builds the ambiguity table for available-package matches. - Execution::TableOutputBase GetMultiplePackageFoundResultTableWithSource(Execution::Context& context); - // Ensures that there is at least one result in the search. // Required Args: bool indicating if the search result is from installed source // Inputs: SearchResult diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 6b12101b88..001d9d693c 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -950,10 +950,12 @@ TEST_CASE("PackageSelection_CandidateRowIdentity", "[PackageSelection][workflow] TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow]") { TestUserSettings settings; + settings.Set(true); TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; bool withSource = GENERATE(false, true); bool available = GENERATE(false, true); - bool prompt = GENERATE(false, true); + bool prompt = withSource && GENERATE(false, true); + CAPTURE(withSource, available, prompt); auto source = std::make_shared(); auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); std::istringstream input{ "2\n" }; @@ -975,6 +977,10 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow header.insert(header.begin(), Resource::LocString{ AppInstaller::Utility::LocIndString{ "#"sv } }); expectedReporter.Info() << Resource::String::PackageSelectionTitle << std::endl << std::endl; } + else + { + expectedReporter.Warn() << (withSource ? Resource::String::MultiplePackagesFound : Resource::String::MultipleInstalledPackagesFound) << std::endl; + } Execution::TableOutputBase expectedTable{ expectedReporter, std::move(header) }; SearchResult result; for (size_t i = 1; i <= 2; ++i) @@ -983,7 +989,7 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow manifest.Id = "Test." + name; manifest.DefaultLocalization.Add(name); auto package = available ? TestCompositePackage::Make(std::vector{ manifest }, source) : - TestCompositePackage::Make(manifest, TestPackage::MetadataMap{}); + TestCompositePackage::Make(manifest, TestPackage::MetadataMap{}, std::vector{}, source); result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); std::vector line{ name, manifest.Id }; if (withSource) @@ -996,24 +1002,28 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow } expectedTable.OutputLine(std::move(line)); } + auto expectedPackage = result.Matches[1].Package; context.Add(std::move(result)); - auto table = withSource ? GetMultiplePackageFoundResultTableWithSource(context) : GetMultiplePackageFoundResultTable(context); - REQUIRE(table.GetRowCount() == size_t{2}); - REQUIRE(output.str().empty()); + context << EnsureOneMatchFromSearchResult(withSource ? OperationType::Install : OperationType::Uninstall, + prompt ? PackageSelectionBehavior::Prompt : PackageSelectionBehavior::Disabled); expectedTable.Complete(); if (prompt) { - context << PromptForSelection(table, Resource::String::PackageSelectionTitle); - expectedReporter.Info() << std::endl << Resource::String::NumberedSelectionPrompt(2) << ' '; + expectedReporter.Info() << std::endl << Resource::String::NumberedSelectionPrompt(2) << ' ' << + Resource::String::PackageSelectionSelected(AppInstaller::Utility::LocIndView{ "Package2"sv }, + AppInstaller::Utility::LocIndView{ "Test.Package2"sv }) << std::endl; + REQUIRE_FALSE(context.IsTerminated()); REQUIRE(context.Get() == size_t{1}); + REQUIRE(context.Get() == expectedPackage); } else { - table.Complete(); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE_FALSE(context.Contains(Execution::Data::SelectedIndex)); REQUIRE(input.peek() == '2'); } - REQUIRE_FALSE(context.IsTerminated()); REQUIRE(output.str() == expectedOutput.str()); REQUIRE(source->CountOfCallsRequiringManifestData == 0); } @@ -1037,20 +1047,30 @@ TEST_CASE("PackageSelection_AmbiguityOutputUnchanged", "[PackageSelection][workf context.Reporter.SetConsoleStreamsForTest(true); context.Reporter.SetStyle(VisualStyle::NoVT); context.Args.AddArg(Execution::Args::Type::DisableInteractivity); - context.Add(result); - TestContext expectedContext{ expectedOutput, input }; - expectedContext.Reporter.SetStyle(VisualStyle::NoVT); - expectedContext.Add(std::move(result)); - expectedContext.Reporter.Warn() << (installed ? Resource::String::MultipleInstalledPackagesFound : Resource::String::MultiplePackagesFound) << std::endl; - auto table = installed ? GetMultiplePackageFoundResultTable(expectedContext) : GetMultiplePackageFoundResultTableWithSource(expectedContext); + context.Add(std::move(result)); + Execution::Reporter expectedReporter{ expectedOutput, input }; + expectedReporter.SetStyle(VisualStyle::NoVT); + expectedReporter.Warn() << (installed ? Resource::String::MultipleInstalledPackagesFound : Resource::String::MultiplePackagesFound) << std::endl; + std::vector header{ Resource::String::SearchName, Resource::String::SearchId }; + std::vector firstRow{ "AppInstaller Test Exe Installer", "AppInstallerCliTest.TestExeInstaller" }; + std::vector secondRow{ "MSIX SDK", "microsoft.msixsdk" }; + if (!installed) + { + header.emplace_back(Resource::String::SearchSource); + firstRow.emplace_back(source->Details.Name); + secondRow.emplace_back(source->Details.Name); + } + Execution::TableOutputBase table{ expectedReporter, std::move(header) }; + table.OutputLine(std::move(firstRow)); + table.OutputLine(std::move(secondRow)); table.Complete(); if (truncated) { - expectedContext.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + expectedReporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; } if (enabled && !installed) { - expectedContext.Reporter.Info() << Resource::String::PackageSelectionRefine << std::endl; + expectedReporter.Info() << Resource::String::PackageSelectionRefine << std::endl; } context << EnsureOneMatchFromSearchResult(installed ? OperationType::Uninstall : OperationType::Install, PackageSelectionBehavior::Prompt); @@ -1068,7 +1088,8 @@ TEST_CASE("PackageSelection_PartialSearchFailureDoesNotPrompt", "[PackageSelecti TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); auto result = source->Search({}); - result.Truncated = GENERATE(false, true); + bool truncated = GENERATE(false, true); + result.Truncated = truncated; result.Failures.push_back({ "BrokenSource", std::make_exception_ptr(wil::ResultException(E_FAIL)) }); std::istringstream input{ "2\n" }; std::ostringstream output; @@ -1078,16 +1099,18 @@ TEST_CASE("PackageSelection_PartialSearchFailureDoesNotPrompt", "[PackageSelecti context.Reporter.SetConsoleStreamsForTest(true); context.Reporter.SetStyle(VisualStyle::NoVT); context.SetFlags(Execution::ContextFlag::ShowSearchResultsOnPartialFailure); - context.Add(result); - TestContext expectedContext{ expectedOutput, input }; - expectedContext.Reporter.SetStyle(VisualStyle::NoVT); - expectedContext.Add(std::move(result)); - expectedContext.Reporter.Info() << std::endl << Resource::String::SearchFailureErrorListMatches << std::endl; - auto table = GetMultiplePackageFoundResultTableWithSource(expectedContext); + context.Add(std::move(result)); + Execution::Reporter expectedReporter{ expectedOutput, input }; + expectedReporter.SetStyle(VisualStyle::NoVT); + expectedReporter.Info() << std::endl << Resource::String::SearchFailureErrorListMatches << std::endl; + Execution::TableOutput<3> table{ expectedReporter, + { Resource::String::SearchName, Resource::String::SearchId, Resource::String::SearchSource } }; + table.OutputLine({ "AppInstaller Test Exe Installer", "AppInstallerCliTest.TestExeInstaller", source->Details.Name }); + table.OutputLine({ "MSIX SDK", "microsoft.msixsdk", source->Details.Name }); table.Complete(); - if (expectedContext.Get().Truncated) + if (truncated) { - expectedContext.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + expectedReporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; } context << HandleSearchResultFailures; From 71c25d8cf785f58adec488ff397e37c4a2c15b35 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:08:53 -0700 Subject: [PATCH 35/36] Remove redundant operation gate from package selection --- .../Workflows/WorkflowBase.cpp | 1 - src/AppInstallerCLITests/PromptFlow.cpp | 19 ++++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 0bcaf306e9..1c02281603 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1399,7 +1399,6 @@ namespace AppInstaller::CLI::Workflow auto table = operationTargetsInstalled ? GetMultiplePackageFoundResultTable(context) : GetMultiplePackageFoundResultTableWithSource(context); bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && - (m_operationType == OperationType::Install || m_operationType == OperationType::Show || m_operationType == OperationType::Download) && Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::InteractivePackageSelection); std::optional selection; if (selectionSupported && !searchResult.Truncated) diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index 001d9d693c..df29a2353d 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -954,7 +954,7 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; bool withSource = GENERATE(false, true); bool available = GENERATE(false, true); - bool prompt = withSource && GENERATE(false, true); + bool prompt = GENERATE(false, true); CAPTURE(withSource, available, prompt); auto source = std::make_shared(); auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); @@ -1068,7 +1068,7 @@ TEST_CASE("PackageSelection_AmbiguityOutputUnchanged", "[PackageSelection][workf { expectedReporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; } - if (enabled && !installed) + if (enabled) { expectedReporter.Info() << Resource::String::PackageSelectionRefine << std::endl; } @@ -1135,13 +1135,15 @@ TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") context.Reporter.SetConsoleStreamsForTest(true); auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); auto result = source->Search({}); - auto operation = OperationType::Install; - auto selectionBehavior = PackageSelectionBehavior::Prompt; + EnsureOneMatchFromSearchResult ensureOneMatch{ OperationType::Install, PackageSelectionBehavior::Prompt }; HRESULT expectedError = APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND; SECTION("Default workflow") { - selectionBehavior = PackageSelectionBehavior::Disabled; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download, + OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, OperationType::Export, + OperationType::Pin, OperationType::Search, OperationType::List, OperationType::Completion); + ensureOneMatch = EnsureOneMatchFromSearchResult(operation); } SECTION("Context disabled") { @@ -1177,14 +1179,9 @@ TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") result.Matches.clear(); expectedError = APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND; } - SECTION("Excluded operation") - { - operation = GENERATE(OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, - OperationType::Export, OperationType::Pin, OperationType::Search, OperationType::List, OperationType::Completion); - } context.Add(std::move(result)); - context << EnsureOneMatchFromSearchResult(operation, selectionBehavior); + context << ensureOneMatch; INFO(output.str()); REQUIRE_TERMINATED_WITH(context, expectedError); REQUIRE_FALSE(context.Contains(Execution::Data::Package)); From a6c4a2c3ef70fb7ba411980287fd9c357362bdb1 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:41:51 -0700 Subject: [PATCH 36/36] Show refinement hints regardless of selection support --- .../#5345 - Interactive package selection.md | 8 +++-- src/AppInstallerCLICore/Resources.h | 1 + .../Workflows/WorkflowBase.cpp | 6 ++-- .../Shared/Strings/en-us/winget.resw | 6 +++- src/AppInstallerCLITests/PromptFlow.cpp | 31 +++++++++++++++---- 5 files changed, 38 insertions(+), 14 deletions(-) diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md index 498777ed29..0f48497320 100644 --- a/doc/specs/#5345 - Interactive package selection.md +++ b/doc/specs/#5345 - Interactive package selection.md @@ -35,7 +35,7 @@ Display candidates in their existing order with stable, one-indexed numbers. A v | Situation | Behavior | | --- | --- | -| Experimental feature disabled (default) | No selection prompts; retain existing ambiguity errors. | +| Experimental feature disabled (default) | No selection prompts; retain ambiguity errors with refinement guidance. | | No match | Existing no-match error. | | One match after existing policy | Continue without prompting. | | Multiple matches for single-package `install`, `show`, or `download` | Prompt if eligible, including `show --versions`. | @@ -49,7 +49,7 @@ Display candidates in their existing order with stable, one-indexed numbers. A v | Redirected input or output, or disabled informational output | Do not prompt. | | `--no-vt` | Use the same text and numeric input without terminal escape sequences. | | Multi-package operations, including individual package contexts | No disambiguation prompts, either per package or up front; retain existing ambiguity errors. | -| Upgrade, uninstall, repair, pin, search, list, or completion | Preserve existing behavior. | +| Upgrade, uninstall, repair, pin, search, list, or completion | No selection prompts. | | COM API, PowerShell cmdlets, or configuration/DSC | No new prompts or API changes. | Apart from the experimental setting, the prompt adds no command-line flags, group policies, manifest fields, or schema versions. Existing interactivity controls and the experimental-features group policy apply. Package validation pipelines and manifest authoring tools are unchanged; manifest examples and schema snippets are not applicable. @@ -81,12 +81,14 @@ For distinct candidates from different sources: Each candidate occupies one row, using the existing ambiguity report's package identity and source. Sources grouped within a candidate do not add rows. Use the same introductory text for every command. Do not add another confirmation after selection. Existing consent prompts still apply. -When prompting is unavailable, retain the candidate list and explain: +Ambiguity errors include the candidate list and refinement guidance, even when interactive selection is disabled: ```text Specify a package with --id --exact --source . ``` +For `configure export`, use `--package-id --source ` instead. + The original version option remains authoritative. ## Capabilities diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index d689e7fb48..1d3ea9b14e 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -535,6 +535,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PackageIsPinned); WINGET_DEFINE_RESOURCE_STRINGID(PackageRequiresDependencies); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefine); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefineForExport); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionSelected); WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionTitle); WINGET_DEFINE_RESOURCE_STRINGID(PendingWorkError); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 1c02281603..d4994cf59d 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1424,10 +1424,8 @@ namespace AppInstaller::CLI::Workflow { context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; } - if (selectionSupported) - { - context.Reporter.Info() << Resource::String::PackageSelectionRefine << std::endl; - } + context.Reporter.Info() << (m_operationType == OperationType::Export ? + Resource::String::PackageSelectionRefineForExport : Resource::String::PackageSelectionRefine) << std::endl; AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); } } diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index fb95c0ac80..75b2d64342 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -425,7 +425,11 @@ They can be configured through the settings file 'winget settings'. Specify a package with --id <ID> --exact --source <SOURCE>. - {Locked="--id","--exact","--source","ID","SOURCE"} Guidance when interactive selection is unavailable. ID and SOURCE are placeholders. + {Locked="--id","--exact","--source","ID","SOURCE"} Guidance when multiple packages match. ID and SOURCE are placeholders. + + + Specify a package with --package-id <ID> --source <SOURCE>. + {Locked="--package-id","--source","ID","SOURCE"} Guidance for configure export when multiple packages match. ID and SOURCE are placeholders. Filter results by name diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index df29a2353d..d1e2ce5d7a 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -617,7 +617,9 @@ TEST_CASE("PackageSelection_FeatureDisabled", "[PackageSelection][workflow]") REQUIRE_FALSE(context.Contains(Execution::Data::Manifest)); REQUIRE(input.peek() == '0'); REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); - REQUIRE(output.str().find(Resource::LocString{ Resource::String::PackageSelectionRefine }.get()) == std::string::npos); + const std::string refinement = Resource::LocString{ Resource::String::PackageSelectionRefine }.get(); + REQUIRE_FALSE(refinement.empty()); + REQUIRE(output.str().find(refinement) != std::string::npos); } TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") @@ -1018,6 +1020,7 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow } else { + expectedReporter.Info() << Resource::String::PackageSelectionRefine << std::endl; REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); REQUIRE_FALSE(context.Contains(Execution::Data::Package)); REQUIRE_FALSE(context.Contains(Execution::Data::SelectedIndex)); @@ -1028,13 +1031,16 @@ TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow REQUIRE(source->CountOfCallsRequiringManifestData == 0); } -TEST_CASE("PackageSelection_AmbiguityOutputUnchanged", "[PackageSelection][workflow]") +TEST_CASE("PackageSelection_AmbiguityRefinement", "[PackageSelection][workflow]") { TestUserSettings settings; bool enabled = GENERATE(false, true); settings.Set(bool{ enabled }); - bool installed = GENERATE(false, true); + auto operation = GENERATE(OperationType::Install, OperationType::Uninstall, OperationType::Export); + auto selectionBehavior = GENERATE(PackageSelectionBehavior::Disabled, PackageSelectionBehavior::Prompt); + bool installed = operation != OperationType::Install; bool truncated = GENERATE(false, true); + CAPTURE(enabled, operation, selectionBehavior, truncated); TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); auto result = source->Search({}); @@ -1068,12 +1074,25 @@ TEST_CASE("PackageSelection_AmbiguityOutputUnchanged", "[PackageSelection][workf { expectedReporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; } - if (enabled) + auto refinementId = operation == OperationType::Export ? + Resource::String::PackageSelectionRefineForExport : Resource::String::PackageSelectionRefine; + const std::string refinement = Resource::LocString{ refinementId }.get(); + REQUIRE_FALSE(refinement.empty()); + REQUIRE(refinement.find("--source") != std::string::npos); + if (operation == OperationType::Export) { - expectedReporter.Info() << Resource::String::PackageSelectionRefine << std::endl; + REQUIRE(refinement.find("--package-id") != std::string::npos); + REQUIRE(refinement.find("--id") == std::string::npos); + REQUIRE(refinement.find("--exact") == std::string::npos); + } + else + { + REQUIRE(refinement.find("--id") != std::string::npos); + REQUIRE(refinement.find("--exact") != std::string::npos); } + expectedReporter.Info() << refinementId << std::endl; - context << EnsureOneMatchFromSearchResult(installed ? OperationType::Uninstall : OperationType::Install, PackageSelectionBehavior::Prompt); + context << EnsureOneMatchFromSearchResult(operation, selectionBehavior); REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); REQUIRE_FALSE(context.Contains(Execution::Data::Package));