From 315a52f3e93b62a550aef64b9333ce12f126f801 Mon Sep 17 00:00:00 2001
From: Patrik Svensson <patrik@patriksvensson.se>
Date: Sun, 16 May 2021 22:45:58 +0200
Subject: [PATCH] Add support for hierarchical list prompts

Closes #412
---
 examples/Console/Prompt/Program.cs            |  13 +-
 .../AcceptChoice.Output.verified.txt          |   0
 ...AutoComplete_BestMatch.Output.verified.txt |   0
 .../AutoComplete_Empty.Output.verified.txt    |   0
 ...utoComplete_NextChoice.Output.verified.txt |   0
 .../ConversionError.Output.verified.txt       |   0
 .../CustomConverter.Output.verified.txt       |   0
 .../CustomValidation.Output.verified.txt      |   0
 .../DefaultValue.Output.verified.txt          |   0
 .../InvalidChoice.Output.verified.txt         |   0
 .../SecretDefaultValue.Output.verified.txt    |   0
 .../{PromptTests.cs => TextPromptTests.cs}    |   4 +-
 .../Extensions/EnumerableExtensions.cs        |  17 ++
 .../Rendering/RenderHookScope.cs              |   1 +
 .../Widgets/Prompt/IMultiSelectionItem.cs     |  16 ++
 .../Widgets/Prompt/ISelectionItem.cs          |  17 ++
 .../Prompt/List/IListPromptStrategy.cs        |  41 +++
 .../Widgets/Prompt/List/ListPrompt.cs         | 110 ++++++++
 .../Prompt/List/ListPromptConstants.cs        |  12 +
 .../Prompt/List/ListPromptInputResult.cs      |  10 +
 .../Widgets/Prompt/List/ListPromptItem.cs     |  80 ++++++
 .../Prompt/List/ListPromptRenderHook.cs       |  59 ++++
 .../Widgets/Prompt/List/ListPromptState.cs    |  46 ++++
 .../Widgets/Prompt/List/ListPromptTree.cs     |  40 +++
 .../Widgets/Prompt/MultiSelectionPrompt.cs    | 255 ++++++++++++------
 .../Prompt/MultiSelectionPromptExtensions.cs  | 187 ++++++++-----
 .../Prompt/Rendering/RenderableList.cs        | 127 ---------
 .../Rendering/RenderableMultiSelectionList.cs | 113 --------
 .../Rendering/RenderableSelectionList.cs      |  84 ------
 .../Widgets/Prompt/SelectionPrompt.cs         | 183 +++++++++----
 .../Prompt/SelectionPromptExtensions.cs       |  53 +++-
 .../Widgets/Prompt/SelectionType.cs           |  19 ++
 32 files changed, 946 insertions(+), 541 deletions(-)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AcceptChoice.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AutoComplete_BestMatch.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AutoComplete_Empty.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/AutoComplete_NextChoice.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/ConversionError.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/CustomConverter.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/CustomValidation.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/DefaultValue.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/InvalidChoice.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Expectations/Widgets/Prompt/{ => Text}/SecretDefaultValue.Output.verified.txt (100%)
 rename src/Spectre.Console.Tests/Unit/{PromptTests.cs => TextPromptTests.cs} (98%)
 create mode 100644 src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs
 delete mode 100644 src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs
 delete mode 100644 src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs
 delete mode 100644 src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs
 create mode 100644 src/Spectre.Console/Widgets/Prompt/SelectionType.cs

diff --git a/examples/Console/Prompt/Program.cs b/examples/Console/Prompt/Program.cs
index d9a0ac9..5c79041 100644
--- a/examples/Console/Prompt/Program.cs
+++ b/examples/Console/Prompt/Program.cs
@@ -59,13 +59,18 @@ namespace Spectre.Console.Examples
                     .Title("What are your [green]favorite fruits[/]?")
                     .MoreChoicesText("[grey](Move up and down to reveal more fruits)[/]")
                     .InstructionsText("[grey](Press [blue]<space>[/] to toggle a fruit, [green]<enter>[/] to accept)[/]")
+                    .AddChoiceGroup("Berries", new[]
+                    {
+                        "Blackcurrant", "Blueberry", "Cloudberry",
+                        "Elderberry", "Honeyberry", "Mulberry"
+                    })
                     .AddChoices(new[]
                     {
-                        "Apple", "Apricot", "Avocado", "Banana", "Blackcurrant", "Blueberry",
-                        "Cherry", "Cloudberry", "Cocunut", "Date", "Dragonfruit", "Durian",
-                        "Egg plant", "Elderberry", "Fig", "Grape", "Guava", "Honeyberry",
+                        "Apple", "Apricot", "Avocado", "Banana", 
+                        "Cherry", "Cocunut", "Date", "Dragonfruit", "Durian",
+                        "Egg plant",  "Fig", "Grape", "Guava", 
                         "Jackfruit", "Jambul", "Kiwano", "Kiwifruit", "Lime", "Lylo",
-                        "Lychee", "Melon", "Mulberry", "Nectarine", "Orange", "Olive"
+                        "Lychee", "Melon", "Nectarine", "Orange", "Olive"
                     }));
 
             var fruit = favorites.Count == 1 ? favorites[0] : null;
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AcceptChoice.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AcceptChoice.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AcceptChoice.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AcceptChoice.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_BestMatch.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_BestMatch.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_BestMatch.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_BestMatch.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_Empty.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_Empty.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_Empty.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_Empty.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_NextChoice.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_NextChoice.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/AutoComplete_NextChoice.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/AutoComplete_NextChoice.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/ConversionError.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/ConversionError.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/ConversionError.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/ConversionError.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomConverter.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomConverter.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomConverter.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomConverter.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomValidation.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomValidation.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/CustomValidation.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/CustomValidation.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/DefaultValue.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/DefaultValue.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/DefaultValue.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/DefaultValue.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/InvalidChoice.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/InvalidChoice.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/InvalidChoice.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/InvalidChoice.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/SecretDefaultValue.Output.verified.txt b/src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/SecretDefaultValue.Output.verified.txt
similarity index 100%
rename from src/Spectre.Console.Tests/Expectations/Widgets/Prompt/SecretDefaultValue.Output.verified.txt
rename to src/Spectre.Console.Tests/Expectations/Widgets/Prompt/Text/SecretDefaultValue.Output.verified.txt
diff --git a/src/Spectre.Console.Tests/Unit/PromptTests.cs b/src/Spectre.Console.Tests/Unit/TextPromptTests.cs
similarity index 98%
rename from src/Spectre.Console.Tests/Unit/PromptTests.cs
rename to src/Spectre.Console.Tests/Unit/TextPromptTests.cs
index 02ce5c3..1f28fad 100644
--- a/src/Spectre.Console.Tests/Unit/PromptTests.cs
+++ b/src/Spectre.Console.Tests/Unit/TextPromptTests.cs
@@ -9,8 +9,8 @@ using Spectre.Verify.Extensions;
 namespace Spectre.Console.Tests.Unit
 {
     [UsesVerify]
-    [ExpectationPath("Widgets/Prompt")]
-    public sealed class PromptTests
+    [ExpectationPath("Widgets/Prompt/Text")]
+    public sealed class TextPromptTests
     {
         [Fact]
         [Expectation("ConversionError")]
diff --git a/src/Spectre.Console/Extensions/EnumerableExtensions.cs b/src/Spectre.Console/Extensions/EnumerableExtensions.cs
index 87b754f..3dec660 100644
--- a/src/Spectre.Console/Extensions/EnumerableExtensions.cs
+++ b/src/Spectre.Console/Extensions/EnumerableExtensions.cs
@@ -6,6 +6,23 @@ namespace Spectre.Console
 {
     internal static class EnumerableExtensions
     {
+        // List.Reverse clashes with IEnumerable<T>.Reverse, so this method only exists
+        // so we won't have to cast List<T> to IEnumerable<T>.
+        public static IEnumerable<T> ReverseEnumerable<T>(this IEnumerable<T> source)
+        {
+            if (source is null)
+            {
+                throw new ArgumentNullException(nameof(source));
+            }
+
+            return source.Reverse();
+        }
+
+        public static bool None<T>(this IEnumerable<T> source, Func<T, bool> predicate)
+        {
+            return !source.Any(predicate);
+        }
+
         public static IEnumerable<T> Repeat<T>(this IEnumerable<T> source, int count)
         {
             while (count-- > 0)
diff --git a/src/Spectre.Console/Rendering/RenderHookScope.cs b/src/Spectre.Console/Rendering/RenderHookScope.cs
index c646bfb..e6e7e6a 100644
--- a/src/Spectre.Console/Rendering/RenderHookScope.cs
+++ b/src/Spectre.Console/Rendering/RenderHookScope.cs
@@ -19,6 +19,7 @@ namespace Spectre.Console.Rendering
         {
             _console = console ?? throw new ArgumentNullException(nameof(console));
             _hook = hook ?? throw new ArgumentNullException(nameof(hook));
+
             _console.Pipeline.Attach(_hook);
         }
 
diff --git a/src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs b/src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs
new file mode 100644
index 0000000..c587adc
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/IMultiSelectionItem.cs
@@ -0,0 +1,16 @@
+namespace Spectre.Console
+{
+    /// <summary>
+    /// Represent a multi selection prompt item.
+    /// </summary>
+    /// <typeparam name="T">The data type.</typeparam>
+    public interface IMultiSelectionItem<T> : ISelectionItem<T>
+        where T : notnull
+    {
+        /// <summary>
+        /// Selects the item.
+        /// </summary>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        IMultiSelectionItem<T> Select();
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs b/src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs
new file mode 100644
index 0000000..ffe189d
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/ISelectionItem.cs
@@ -0,0 +1,17 @@
+namespace Spectre.Console
+{
+    /// <summary>
+    /// Represent a selection item.
+    /// </summary>
+    /// <typeparam name="T">The data type.</typeparam>
+    public interface ISelectionItem<T>
+        where T : notnull
+    {
+        /// <summary>
+        /// Adds a child to the item.
+        /// </summary>
+        /// <param name="child">The child to add.</param>
+        /// <returns>A new <see cref="ISelectionItem{T}"/> instance representing the child.</returns>
+        ISelectionItem<T> AddChild(T child);
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs b/src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs
new file mode 100644
index 0000000..54614c0
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/IListPromptStrategy.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using Spectre.Console.Rendering;
+
+namespace Spectre.Console
+{
+    /// <summary>
+    /// Represents a strategy for a list prompt.
+    /// </summary>
+    /// <typeparam name="T">The list data type.</typeparam>
+    internal interface IListPromptStrategy<T>
+        where T : notnull
+    {
+        /// <summary>
+        /// Handles any input received from the user.
+        /// </summary>
+        /// <param name="key">The key that was pressed.</param>
+        /// <param name="state">The current state.</param>
+        /// <returns>A result representing an action.</returns>
+        ListPromptInputResult HandleInput(ConsoleKeyInfo key, ListPromptState<T> state);
+
+        /// <summary>
+        /// Calculates the page size.
+        /// </summary>
+        /// <param name="console">The console.</param>
+        /// <param name="totalItemCount">The total number of items.</param>
+        /// <param name="requestedPageSize">The requested number of items to show.</param>
+        /// <returns>The page size that should be used.</returns>
+        public int CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize);
+
+        /// <summary>
+        /// Builds a <see cref="IRenderable"/> from the current state.
+        /// </summary>
+        /// <param name="console">The console.</param>
+        /// <param name="scrollable">Whether or not the list is scrollable.</param>
+        /// <param name="cursorIndex">The cursor index.</param>
+        /// <param name="items">The visible items.</param>
+        /// <returns>A <see cref="IRenderable"/> representing the items.</returns>
+        public IRenderable Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items);
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs
new file mode 100644
index 0000000..886c593
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPrompt.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Linq;
+using Spectre.Console.Rendering;
+
+namespace Spectre.Console
+{
+    internal sealed class ListPrompt<T>
+        where T : notnull
+    {
+        private readonly IAnsiConsole _console;
+        private readonly IListPromptStrategy<T> _strategy;
+
+        public ListPrompt(IAnsiConsole console, IListPromptStrategy<T> strategy)
+        {
+            _console = console ?? throw new ArgumentNullException(nameof(console));
+            _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy));
+        }
+
+        public ListPromptState<T> Show(ListPromptTree<T> tree, int requestedPageSize = 15)
+        {
+            if (tree is null)
+            {
+                throw new ArgumentNullException(nameof(tree));
+            }
+
+            if (!_console.Profile.Capabilities.Interactive)
+            {
+                throw new NotSupportedException(
+                    "Cannot show selection prompt since the current " +
+                    "terminal isn't interactive.");
+            }
+
+            if (!_console.Profile.Capabilities.Ansi)
+            {
+                throw new NotSupportedException(
+                    "Cannot show selection prompt since the current " +
+                    "terminal does not support ANSI escape sequences.");
+            }
+
+            var nodes = tree.Traverse().ToList();
+            var state = new ListPromptState<T>(nodes, _strategy.CalculatePageSize(_console, nodes.Count, requestedPageSize));
+            var hook = new ListPromptRenderHook<T>(_console, () => BuildRenderable(state));
+
+            using (new RenderHookScope(_console, hook))
+            {
+                _console.Cursor.Hide();
+                hook.Refresh();
+
+                while (true)
+                {
+                    var key = _console.Input.ReadKey(true);
+
+                    var result = _strategy.HandleInput(key, state);
+                    if (result == ListPromptInputResult.Submit)
+                    {
+                        break;
+                    }
+
+                    if (state.Update(key.Key) || result == ListPromptInputResult.Refresh)
+                    {
+                        hook.Refresh();
+                    }
+                }
+            }
+
+            hook.Clear();
+            _console.Cursor.Show();
+
+            return state;
+        }
+
+        private IRenderable BuildRenderable(ListPromptState<T> state)
+        {
+            var pageSize = state.PageSize;
+            var middleOfList = pageSize / 2;
+
+            var skip = 0;
+            var take = state.ItemCount;
+            var cursorIndex = state.Index;
+
+            var scrollable = state.ItemCount > pageSize;
+            if (scrollable)
+            {
+                skip = Math.Max(0, state.Index - middleOfList);
+                take = Math.Min(pageSize, state.ItemCount - skip);
+
+                if (state.ItemCount - state.Index < middleOfList)
+                {
+                    // Pointer should be below the end of the list
+                    var diff = middleOfList - (state.ItemCount - state.Index);
+                    skip -= diff;
+                    take += diff;
+                    cursorIndex = middleOfList + diff;
+                }
+                else
+                {
+                    // Take skip into account
+                    cursorIndex -= skip;
+                }
+            }
+
+            // Build the renderable
+            return _strategy.Render(
+                _console,
+                scrollable, cursorIndex,
+                state.Items.Skip(skip).Take(take)
+                    .Select((node, index) => (index, node)));
+        }
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs
new file mode 100644
index 0000000..7a1f6d7
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptConstants.cs
@@ -0,0 +1,12 @@
+namespace Spectre.Console
+{
+    internal sealed class ListPromptConstants
+    {
+        public const string Arrow = ">";
+        public const string Checkbox = "[[ ]]";
+        public const string SelectedCheckbox = "[[[blue]X[/]]]";
+        public const string GroupSelectedCheckbox = "[[[grey]X[/]]]";
+        public const string InstructionsMarkup = "[grey](Press <space> to select, <enter> to accept)[/]";
+        public const string MoreChoicesMarkup = "[grey](Move up and down to reveal more choices)[/]";
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs
new file mode 100644
index 0000000..0cdc00e
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptInputResult.cs
@@ -0,0 +1,10 @@
+namespace Spectre.Console
+{
+    internal enum ListPromptInputResult
+    {
+        None = 0,
+        Refresh = 1,
+        Submit = 2,
+        Abort = 3,
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs
new file mode 100644
index 0000000..c7e321b
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptItem.cs
@@ -0,0 +1,80 @@
+using System.Collections.Generic;
+
+namespace Spectre.Console
+{
+    internal sealed class ListPromptItem<T> : IMultiSelectionItem<T>
+        where T : notnull
+    {
+        public T Data { get; }
+        public ListPromptItem<T>? Parent { get; }
+        public List<ListPromptItem<T>> Children { get; }
+        public int Depth { get; }
+        public bool Selected { get; set; }
+
+        public bool IsGroup => Children.Count > 0;
+
+        public ListPromptItem(T data, ListPromptItem<T>? parent = null)
+        {
+            Data = data;
+            Parent = parent;
+            Children = new List<ListPromptItem<T>>();
+            Depth = CalculateDepth(parent);
+        }
+
+        public IMultiSelectionItem<T> Select()
+        {
+            Selected = true;
+            return this;
+        }
+
+        public ISelectionItem<T> AddChild(T item)
+        {
+            var node = new ListPromptItem<T>(item, this);
+            Children.Add(node);
+            return node;
+        }
+
+        public IEnumerable<ListPromptItem<T>> Traverse(bool includeSelf)
+        {
+            var stack = new Stack<ListPromptItem<T>>();
+
+            if (includeSelf)
+            {
+                stack.Push(this);
+            }
+            else
+            {
+                foreach (var child in Children)
+                {
+                    stack.Push(child);
+                }
+            }
+
+            while (stack.Count > 0)
+            {
+                var current = stack.Pop();
+                yield return current;
+
+                if (current.Children.Count > 0)
+                {
+                    foreach (var child in current.Children.ReverseEnumerable())
+                    {
+                        stack.Push(child);
+                    }
+                }
+            }
+        }
+
+        private static int CalculateDepth(ListPromptItem<T>? parent)
+        {
+            var level = 0;
+            while (parent != null)
+            {
+                level++;
+                parent = parent.Parent;
+            }
+
+            return level;
+        }
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs
new file mode 100644
index 0000000..6b619f3
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptRenderHook.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using Spectre.Console.Rendering;
+
+namespace Spectre.Console
+{
+    internal sealed class ListPromptRenderHook<T> : IRenderHook
+        where T : notnull
+    {
+        private readonly LiveRenderable _live;
+        private readonly object _lock;
+        private readonly IAnsiConsole _console;
+        private readonly Func<IRenderable> _builder;
+        private bool _dirty;
+
+        public ListPromptRenderHook(
+            IAnsiConsole console,
+            Func<IRenderable> builder)
+        {
+            _live = new LiveRenderable();
+            _lock = new object();
+            _console = console;
+            _builder = builder;
+            _dirty = true;
+        }
+
+        public void Clear()
+        {
+            _console.Write(_live.RestoreCursor());
+        }
+
+        public void Refresh()
+        {
+            _dirty = true;
+            _console.Write(new ControlCode(string.Empty));
+        }
+
+        public IEnumerable<IRenderable> Process(RenderContext context, IEnumerable<IRenderable> renderables)
+        {
+            lock (_lock)
+            {
+                if (!_live.HasRenderable || _dirty)
+                {
+                    _live.SetRenderable(_builder());
+                    _dirty = false;
+                }
+
+                yield return _live.PositionCursor();
+
+                foreach (var renderable in renderables)
+                {
+                    yield return renderable;
+                }
+
+                yield return _live;
+            }
+        }
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs
new file mode 100644
index 0000000..0d1fa42
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptState.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+
+namespace Spectre.Console
+{
+    internal sealed class ListPromptState<T>
+        where T : notnull
+    {
+        public int Index { get; private set; }
+        public int ItemCount => Items.Count;
+        public int PageSize { get; }
+        public IReadOnlyList<ListPromptItem<T>> Items { get; }
+
+        public ListPromptItem<T> Current => Items[Index];
+
+        public ListPromptState(IReadOnlyList<ListPromptItem<T>> items, int pageSize)
+        {
+            Index = 0;
+            Items = items;
+            PageSize = pageSize;
+        }
+
+        public bool Update(ConsoleKey key)
+        {
+            var index = key switch
+            {
+                ConsoleKey.UpArrow => Index - 1,
+                ConsoleKey.DownArrow => Index + 1,
+                ConsoleKey.Home => 0,
+                ConsoleKey.End => ItemCount - 1,
+                ConsoleKey.PageUp => Index - PageSize,
+                ConsoleKey.PageDown => Index + PageSize,
+                _ => Index,
+            };
+
+            index = index.Clamp(0, ItemCount - 1);
+            if (index != Index)
+            {
+                Index = index;
+                return true;
+            }
+
+            return false;
+        }
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs b/src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs
new file mode 100644
index 0000000..82ef5ca
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/List/ListPromptTree.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+
+namespace Spectre.Console
+{
+    internal sealed class ListPromptTree<T>
+        where T : notnull
+    {
+        private readonly List<ListPromptItem<T>> _roots;
+
+        public ListPromptTree()
+        {
+            _roots = new List<ListPromptItem<T>>();
+        }
+
+        public void Add(ListPromptItem<T> node)
+        {
+            _roots.Add(node);
+        }
+
+        public IEnumerable<ListPromptItem<T>> Traverse()
+        {
+            foreach (var root in _roots)
+            {
+                var stack = new Stack<ListPromptItem<T>>();
+                stack.Push(root);
+
+                while (stack.Count > 0)
+                {
+                    var current = stack.Pop();
+                    yield return current;
+
+                    foreach (var child in current.Children.ReverseEnumerable())
+                    {
+                        stack.Push(child);
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs
index 18696f9..12e4f23 100644
--- a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs
+++ b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPrompt.cs
@@ -7,32 +7,19 @@ using Spectre.Console.Rendering;
 namespace Spectre.Console
 {
     /// <summary>
-    /// Represents a list prompt.
+    /// Represents a multi selection list prompt.
     /// </summary>
     /// <typeparam name="T">The prompt result type.</typeparam>
-    public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>
+    public sealed class MultiSelectionPrompt<T> : IPrompt<List<T>>, IListPromptStrategy<T>
+        where T : notnull
     {
+        private readonly ListPromptTree<T> _tree;
+
         /// <summary>
         /// Gets or sets the title.
         /// </summary>
         public string? Title { get; set; }
 
-        /// <summary>
-        /// Gets the choices.
-        /// </summary>
-        public List<T> Choices { get; }
-
-        /// <summary>
-        /// Gets the initially selected choices.
-        /// </summary>
-        public HashSet<int> Selected { get; }
-
-        /// <summary>
-        /// Gets or sets the converter to get the display string for a choice. By default
-        /// the corresponding <see cref="TypeConverter"/> is used.
-        /// </summary>
-        public Func<T, string>? Converter { get; set; } = TypeConverterHelper.ConvertToString;
-
         /// <summary>
         /// Gets or sets the page size.
         /// Defaults to <c>10</c>.
@@ -44,6 +31,18 @@ namespace Spectre.Console
         /// </summary>
         public Style? HighlightStyle { get; set; }
 
+        /// <summary>
+        /// Gets or sets the converter to get the display string for a choice. By default
+        /// the corresponding <see cref="TypeConverter"/> is used.
+        /// </summary>
+        public Func<T, string>? Converter { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether or not
+        /// at least one selection is required.
+        /// </summary>
+        public bool Required { get; set; } = true;
+
         /// <summary>
         /// Gets or sets the text that will be displayed if there are more choices to show.
         /// </summary>
@@ -55,89 +54,183 @@ namespace Spectre.Console
         public string? InstructionsText { get; set; }
 
         /// <summary>
-        /// Gets or sets a value indicating whether or not
-        /// at least one selection is required.
+        /// Gets or sets the selection mode.
+        /// Defaults to <see cref="SelectionMode.Leaf"/>.
         /// </summary>
-        public bool Required { get; set; } = true;
+        public SelectionMode Mode { get; set; } = SelectionMode.Leaf;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="MultiSelectionPrompt{T}"/> class.
         /// </summary>
         public MultiSelectionPrompt()
         {
-            Choices = new List<T>();
-            Selected = new HashSet<int>();
+            _tree = new ListPromptTree<T>();
+        }
+
+        /// <summary>
+        /// Adds a choice.
+        /// </summary>
+        /// <param name="item">The item to add.</param>
+        /// <returns>A <see cref="IMultiSelectionItem{T}"/> so that multiple calls can be chained.</returns>
+        public IMultiSelectionItem<T> AddChoice(T item)
+        {
+            var node = new ListPromptItem<T>(item);
+            _tree.Add(node);
+            return node;
         }
 
         /// <inheritdoc/>
         public List<T> Show(IAnsiConsole console)
         {
-            if (console is null)
+            // Create the list prompt
+            var prompt = new ListPrompt<T>(console, this);
+            var result = prompt.Show(_tree, PageSize);
+
+            if (Mode == SelectionMode.Leaf)
             {
-                throw new ArgumentNullException(nameof(console));
+                return result.Items
+                    .Where(x => x.Selected && x.Children.Count == 0)
+                    .Select(x => x.Data)
+                    .ToList();
             }
 
-            if (!console.Profile.Capabilities.Interactive)
-            {
-                throw new NotSupportedException(
-                    "Cannot show multi selection prompt since the current " +
-                    "terminal isn't interactive.");
-            }
+            return result.Items
+                .Where(x => x.Selected)
+                .Select(x => x.Data)
+                .ToList();
+        }
 
-            if (!console.Profile.Capabilities.Ansi)
+        /// <inheritdoc/>
+        ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state)
+        {
+            if (key.Key == ConsoleKey.Enter)
             {
-                throw new NotSupportedException(
-                    "Cannot show multi selection prompt since the current " +
-                    "terminal does not support ANSI escape sequences.");
-            }
-
-            return console.RunExclusive(() =>
-            {
-                var converter = Converter ?? TypeConverterHelper.ConvertToString;
-                var list = new RenderableMultiSelectionList<T>(
-                    console, Title, PageSize, Choices,
-                    Selected, converter, HighlightStyle,
-                    MoreChoicesText, InstructionsText);
-
-                using (new RenderHookScope(console, list))
+                if (Required && state.Items.None(x => x.Selected))
                 {
-                    console.Cursor.Hide();
-                    list.Redraw();
-
-                    while (true)
-                    {
-                        var key = console.Input.ReadKey(true);
-                        if (key.Key == ConsoleKey.Enter)
-                        {
-                            if (Required && list.Selections.Count == 0)
-                            {
-                                continue;
-                            }
-
-                            break;
-                        }
-
-                        if (key.Key == ConsoleKey.Spacebar)
-                        {
-                            list.Select();
-                            list.Redraw();
-                            continue;
-                        }
-
-                        if (list.Update(key.Key))
-                        {
-                            list.Redraw();
-                        }
-                    }
+                    // Selection not permitted
+                    return ListPromptInputResult.None;
                 }
 
-                list.Clear();
-                console.Cursor.Show();
+                // Submit
+                return ListPromptInputResult.Submit;
+            }
 
-                return list.Selections
-                    .Select(index => Choices[index])
-                    .ToList();
-            });
+            if (key.Key == ConsoleKey.Spacebar)
+            {
+                var current = state.Items[state.Index];
+                var select = !current.Selected;
+
+                if (Mode == SelectionMode.Leaf)
+                {
+                    // Select the node and all it's children
+                    foreach (var item in current.Traverse(includeSelf: true))
+                    {
+                        item.Selected = select;
+                    }
+
+                    // Visit every parent and evaluate if it's selection
+                    // status need to be updated
+                    var parent = current.Parent;
+                    while (parent != null)
+                    {
+                        parent.Selected = parent.Traverse(includeSelf: false).All(x => x.Selected);
+                        parent = parent.Parent;
+                    }
+                }
+                else
+                {
+                    current.Selected = !current.Selected;
+                }
+
+                // Refresh the list
+                return ListPromptInputResult.Refresh;
+            }
+
+            return ListPromptInputResult.None;
+        }
+
+        /// <inheritdoc/>
+        int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize)
+        {
+            // The instructions take up two rows including a blank line
+            var extra = 2;
+            if (Title != null)
+            {
+                // Title takes up two rows including a blank line
+                extra += 2;
+            }
+
+            // Scrolling?
+            if (totalItemCount > requestedPageSize)
+            {
+                // The scrolling instructions takes up one row
+                extra++;
+            }
+
+            var pageSize = requestedPageSize;
+            if (pageSize > console.Profile.Height - extra)
+            {
+                pageSize = console.Profile.Height - extra;
+            }
+
+            return pageSize;
+        }
+
+        /// <inheritdoc/>
+        IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
+        {
+            var list = new List<IRenderable>();
+            var highlightStyle = HighlightStyle ?? new Style(foreground: Color.Blue);
+
+            if (Title != null)
+            {
+                list.Add(new Markup(Title));
+            }
+
+            var grid = new Grid();
+            grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap());
+
+            if (Title != null)
+            {
+                grid.AddEmptyRow();
+            }
+
+            foreach (var item in items)
+            {
+                var current = item.Index == cursorIndex;
+                var style = current ? highlightStyle : Style.Plain;
+
+                var indent = new string(' ', item.Node.Depth * 2);
+                var prompt = item.Index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length);
+
+                var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Node.Data) ?? item.Node.Data.ToString() ?? "?";
+                if (current)
+                {
+                    text = text.RemoveMarkup();
+                }
+
+                var checkbox = item.Node.Selected
+                    ? (item.Node.IsGroup && Mode == SelectionMode.Leaf
+                        ? ListPromptConstants.GroupSelectedCheckbox : ListPromptConstants.SelectedCheckbox)
+                    : ListPromptConstants.Checkbox;
+
+                grid.AddRow(new Markup(indent + prompt + " " + checkbox + " " + text, style));
+            }
+
+            list.Add(grid);
+            list.Add(Text.Empty);
+
+            if (scrollable)
+            {
+                // There are more choices
+                list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
+            }
+
+            // Instructions
+            list.Add(new Markup(InstructionsText ?? ListPromptConstants.InstructionsMarkup));
+
+            // Combine all items
+            return new Rows(list);
         }
     }
-}
\ No newline at end of file
+}
diff --git a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs
index ee2c6c8..4575ba6 100644
--- a/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs
+++ b/src/Spectre.Console/Widgets/Prompt/MultiSelectionPromptExtensions.cs
@@ -9,20 +9,48 @@ namespace Spectre.Console
     public static class MultiSelectionPromptExtensions
     {
         /// <summary>
-        /// Adds a choice.
+        /// Sets the selection mode.
         /// </summary>
         /// <typeparam name="T">The prompt result type.</typeparam>
         /// <param name="obj">The prompt.</param>
-        /// <param name="choice">The choice to add.</param>
+        /// <param name="mode">The selection mode.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
-        public static MultiSelectionPrompt<T> AddChoice<T>(this MultiSelectionPrompt<T> obj, T choice)
+        public static MultiSelectionPrompt<T> Mode<T>(this MultiSelectionPrompt<T> obj, SelectionMode mode)
+            where T : notnull
         {
             if (obj is null)
             {
                 throw new ArgumentNullException(nameof(obj));
             }
 
-            obj.Choices.Add(choice);
+            obj.Mode = mode;
+            return obj;
+        }
+
+        /// <summary>
+        /// Adds a choice.
+        /// </summary>
+        /// <typeparam name="T">The prompt result type.</typeparam>
+        /// <param name="obj">The prompt.</param>
+        /// <param name="choice">The choice to add.</param>
+        /// <param name="configurator">The configurator for the choice.</param>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, T choice, Action<IMultiSelectionItem<T>> configurator)
+            where T : notnull
+        {
+            if (obj is null)
+            {
+                throw new ArgumentNullException(nameof(obj));
+            }
+
+            if (configurator is null)
+            {
+                throw new ArgumentNullException(nameof(configurator));
+            }
+
+            var result = obj.AddChoice(choice);
+            configurator(result);
+
             return obj;
         }
 
@@ -34,78 +62,16 @@ namespace Spectre.Console
         /// <param name="choices">The choices to add.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, params T[] choices)
+            where T : notnull
         {
             if (obj is null)
             {
                 throw new ArgumentNullException(nameof(obj));
             }
 
-            obj.Choices.AddRange(choices);
-            return obj;
-        }
-
-        /// <summary>
-        /// Marks an item as selected.
-        /// </summary>
-        /// <typeparam name="T">The prompt result type.</typeparam>
-        /// <param name="obj">The prompt.</param>
-        /// <param name="index">The index of the item to select.</param>
-        /// <returns>The same instance so that multiple calls can be chained.</returns>
-        public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, int index)
-        {
-            if (obj is null)
+            foreach (var choice in choices)
             {
-                throw new ArgumentNullException(nameof(obj));
-            }
-
-            if (index < 0)
-            {
-                throw new ArgumentException("Index must be greater than zero", nameof(index));
-            }
-
-            obj.Selected.Add(index);
-            return obj;
-        }
-
-        /// <summary>
-        /// Marks multiple items as selected.
-        /// </summary>
-        /// <typeparam name="T">The prompt result type.</typeparam>
-        /// <param name="obj">The prompt.</param>
-        /// <param name="indices">The indices of the items to select.</param>
-        /// <returns>The same instance so that multiple calls can be chained.</returns>
-        public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, params int[] indices)
-        {
-            if (obj is null)
-            {
-                throw new ArgumentNullException(nameof(obj));
-            }
-
-            foreach (var index in indices)
-            {
-                Select(obj, index);
-            }
-
-            return obj;
-        }
-
-        /// <summary>
-        /// Marks multiple items as selected.
-        /// </summary>
-        /// <typeparam name="T">The prompt result type.</typeparam>
-        /// <param name="obj">The prompt.</param>
-        /// <param name="indices">The indices of the items to select.</param>
-        /// <returns>The same instance so that multiple calls can be chained.</returns>
-        public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, IEnumerable<int> indices)
-        {
-            if (obj is null)
-            {
-                throw new ArgumentNullException(nameof(obj));
-            }
-
-            foreach (var index in indices)
-            {
-                Select(obj, index);
+                obj.AddChoice(choice);
             }
 
             return obj;
@@ -119,13 +85,85 @@ namespace Spectre.Console
         /// <param name="choices">The choices to add.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> AddChoices<T>(this MultiSelectionPrompt<T> obj, IEnumerable<T> choices)
+            where T : notnull
         {
             if (obj is null)
             {
                 throw new ArgumentNullException(nameof(obj));
             }
 
-            obj.Choices.AddRange(choices);
+            foreach (var choice in choices)
+            {
+                obj.AddChoice(choice);
+            }
+
+            return obj;
+        }
+
+        /// <summary>
+        /// Adds multiple grouped choices.
+        /// </summary>
+        /// <typeparam name="T">The prompt result type.</typeparam>
+        /// <param name="obj">The prompt.</param>
+        /// <param name="group">The group.</param>
+        /// <param name="choices">The choices to add.</param>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        public static MultiSelectionPrompt<T> AddChoiceGroup<T>(this MultiSelectionPrompt<T> obj, T group, IEnumerable<T> choices)
+            where T : notnull
+        {
+            if (obj is null)
+            {
+                throw new ArgumentNullException(nameof(obj));
+            }
+
+            var root = obj.AddChoice(group);
+            foreach (var choice in choices)
+            {
+                root.AddChild(choice);
+            }
+
+            return obj;
+        }
+
+        /// <summary>
+        /// Marks an item as selected.
+        /// </summary>
+        /// <typeparam name="T">The prompt result type.</typeparam>
+        /// <param name="obj">The prompt.</param>
+        /// <param name="index">The index of the item to select.</param>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        [Obsolete("Selection by index has been made obsolete", error: true)]
+        public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, int index)
+            where T : notnull
+        {
+            return obj;
+        }
+
+        /// <summary>
+        /// Marks multiple items as selected.
+        /// </summary>
+        /// <typeparam name="T">The prompt result type.</typeparam>
+        /// <param name="obj">The prompt.</param>
+        /// <param name="indices">The indices of the items to select.</param>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        [Obsolete("Selection by index has been made obsolete", error: true)]
+        public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, params int[] indices)
+            where T : notnull
+        {
+            return obj;
+        }
+
+        /// <summary>
+        /// Marks multiple items as selected.
+        /// </summary>
+        /// <typeparam name="T">The prompt result type.</typeparam>
+        /// <param name="obj">The prompt.</param>
+        /// <param name="indices">The indices of the items to select.</param>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        [Obsolete("Selection by index has been made obsolete", error: true)]
+        public static MultiSelectionPrompt<T> Select<T>(this MultiSelectionPrompt<T> obj, IEnumerable<int> indices)
+            where T : notnull
+        {
             return obj;
         }
 
@@ -137,6 +175,7 @@ namespace Spectre.Console
         /// <param name="title">The title markup text.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> Title<T>(this MultiSelectionPrompt<T> obj, string? title)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -155,6 +194,7 @@ namespace Spectre.Console
         /// <param name="pageSize">The number of choices that are displayed to the user.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> PageSize<T>(this MultiSelectionPrompt<T> obj, int pageSize)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -178,6 +218,7 @@ namespace Spectre.Console
         /// <param name="highlightStyle">The highlight style of the selected choice.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> HighlightStyle<T>(this MultiSelectionPrompt<T> obj, Style highlightStyle)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -196,6 +237,7 @@ namespace Spectre.Console
         /// <param name="text">The text to display.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> MoreChoicesText<T>(this MultiSelectionPrompt<T> obj, string? text)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -214,6 +256,7 @@ namespace Spectre.Console
         /// <param name="text">The text to display.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> InstructionsText<T>(this MultiSelectionPrompt<T> obj, string? text)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -231,6 +274,7 @@ namespace Spectre.Console
         /// <param name="obj">The prompt.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> NotRequired<T>(this MultiSelectionPrompt<T> obj)
+            where T : notnull
         {
             return Required(obj, false);
         }
@@ -242,6 +286,7 @@ namespace Spectre.Console
         /// <param name="obj">The prompt.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj)
+            where T : notnull
         {
             return Required(obj, true);
         }
@@ -254,6 +299,7 @@ namespace Spectre.Console
         /// <param name="required">Whether or not at least one choice must be selected.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> Required<T>(this MultiSelectionPrompt<T> obj, bool required)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -272,6 +318,7 @@ namespace Spectre.Console
         /// <param name="displaySelector">The function to get a display string for a given choice.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static MultiSelectionPrompt<T> UseConverter<T>(this MultiSelectionPrompt<T> obj, Func<T, string>? displaySelector)
+            where T : notnull
         {
             if (obj is null)
             {
diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs
deleted file mode 100644
index 13a327d..0000000
--- a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableList.cs
+++ /dev/null
@@ -1,127 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Spectre.Console.Rendering;
-
-namespace Spectre.Console
-{
-    internal abstract class RenderableList<T> : IRenderHook
-    {
-        private readonly LiveRenderable _live;
-        private readonly object _lock;
-        private readonly IAnsiConsole _console;
-        private readonly int _requestedPageSize;
-        private readonly List<T> _choices;
-        private readonly Func<T, string> _converter;
-        private int _index;
-
-        public int Index => _index;
-
-        public RenderableList(IAnsiConsole console, int requestedPageSize, List<T> choices, Func<T, string>? converter)
-        {
-            _console = console;
-            _requestedPageSize = requestedPageSize;
-            _choices = choices;
-            _converter = converter ?? throw new ArgumentNullException(nameof(converter));
-            _live = new LiveRenderable();
-            _lock = new object();
-            _index = 0;
-        }
-
-        protected abstract int CalculatePageSize(int requestedPageSize);
-        protected abstract IRenderable Build(int pointerIndex, bool scrollable, IEnumerable<(int Original, int Index, string Item)> choices);
-
-        public void Clear()
-        {
-            _console.Write(_live.RestoreCursor());
-        }
-
-        public void Redraw()
-        {
-            _console.Write(new ControlCode(string.Empty));
-        }
-
-        public bool Update(ConsoleKey key)
-        {
-            var index = key switch
-            {
-                ConsoleKey.UpArrow => _index - 1,
-                ConsoleKey.DownArrow => _index + 1,
-                ConsoleKey.Home => 0,
-                ConsoleKey.End => _choices.Count - 1,
-                ConsoleKey.PageUp => _index - CalculatePageSize(_requestedPageSize),
-                ConsoleKey.PageDown => _index + CalculatePageSize(_requestedPageSize),
-                _ => _index,
-            };
-
-            index = index.Clamp(0, _choices.Count - 1);
-            if (index != _index)
-            {
-                _index = index;
-                Build();
-                return true;
-            }
-
-            return false;
-        }
-
-        public IEnumerable<IRenderable> Process(RenderContext context, IEnumerable<IRenderable> renderables)
-        {
-            lock (_lock)
-            {
-                if (!_live.HasRenderable)
-                {
-                    Build();
-                }
-
-                yield return _live.PositionCursor();
-
-                foreach (var renderable in renderables)
-                {
-                    yield return renderable;
-                }
-
-                yield return _live;
-            }
-        }
-
-        protected void Build()
-        {
-            var pageSize = CalculatePageSize(_requestedPageSize);
-            var middleOfList = pageSize / 2;
-
-            var skip = 0;
-            var take = _choices.Count;
-            var pointer = _index;
-
-            var scrollable = _choices.Count > pageSize;
-            if (scrollable)
-            {
-                skip = Math.Max(0, _index - middleOfList);
-                take = Math.Min(pageSize, _choices.Count - skip);
-
-                if (_choices.Count - _index < middleOfList)
-                {
-                    // Pointer should be below the end of the list
-                    var diff = middleOfList - (_choices.Count - _index);
-                    skip -= diff;
-                    take += diff;
-                    pointer = middleOfList + diff;
-                }
-                else
-                {
-                    // Take skip into account
-                    pointer -= skip;
-                }
-            }
-
-            // Build the list
-            _live.SetRenderable(Build(
-                pointer,
-                scrollable,
-                _choices.Skip(skip).Take(take)
-                .Enumerate()
-                .Select(x => (skip + x.Index, x.Index, _converter(x.Item)))));
-        }
-    }
-}
diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs
deleted file mode 100644
index f28ace1..0000000
--- a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableMultiSelectionList.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Spectre.Console.Rendering;
-
-namespace Spectre.Console
-{
-    internal sealed class RenderableMultiSelectionList<T> : RenderableList<T>
-    {
-        private const string Checkbox = "[[ ]]";
-        private const string SelectedCheckbox = "[[X]]";
-        private const string MoreChoicesText = "[grey](Move up and down to reveal more choices)[/]";
-        private const string InstructionsText = "[grey](Press <space> to select, <enter> to accept)[/]";
-
-        private readonly IAnsiConsole _console;
-        private readonly string? _title;
-        private readonly Markup _moreChoices;
-        private readonly Markup _instructions;
-        private readonly Style _highlightStyle;
-
-        public HashSet<int> Selections { get; set; }
-
-        public RenderableMultiSelectionList(
-            IAnsiConsole console, string? title, int pageSize,
-            List<T> choices, HashSet<int> selections,
-            Func<T, string>? converter, Style? highlightStyle,
-            string? moreChoicesText, string? instructionsText)
-            : base(console, pageSize, choices, converter)
-        {
-            _console = console ?? throw new ArgumentNullException(nameof(console));
-            _title = title;
-            _highlightStyle = highlightStyle ?? new Style(foreground: Color.Blue);
-            _moreChoices = new Markup(moreChoicesText ?? MoreChoicesText);
-            _instructions = new Markup(instructionsText ?? InstructionsText);
-
-            Selections = new HashSet<int>(selections);
-        }
-
-        public void Select()
-        {
-            if (Selections.Contains(Index))
-            {
-                Selections.Remove(Index);
-            }
-            else
-            {
-                Selections.Add(Index);
-            }
-
-            Build();
-        }
-
-        protected override int CalculatePageSize(int requestedPageSize)
-        {
-            var pageSize = requestedPageSize;
-            if (pageSize > _console.Profile.Height - 5)
-            {
-                pageSize = _console.Profile.Height - 5;
-            }
-
-            return pageSize;
-        }
-
-        protected override IRenderable Build(int pointerIndex, bool scrollable,
-            IEnumerable<(int Original, int Index, string Item)> choices)
-        {
-            var list = new List<IRenderable>();
-
-            if (_title != null)
-            {
-                list.Add(new Markup(_title));
-            }
-
-            var grid = new Grid();
-            grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap());
-            grid.AddColumn(new GridColumn().Padding(0, 0, 0, 0));
-
-            if (_title != null)
-            {
-                grid.AddEmptyRow();
-            }
-
-            foreach (var choice in choices)
-            {
-                var current = choice.Index == pointerIndex;
-                var selected = Selections.Contains(choice.Original);
-
-                var prompt = choice.Index == pointerIndex ? "> " : "  ";
-                var checkbox = selected ? SelectedCheckbox : Checkbox;
-
-                var style = current ? _highlightStyle : Style.Plain;
-                var item = current
-                    ? new Text(choice.Item.RemoveMarkup(), style)
-                    : (IRenderable)new Markup(choice.Item, style);
-
-                grid.AddRow(new Markup(prompt + checkbox, style), item);
-            }
-
-            list.Add(grid);
-            list.Add(Text.Empty);
-
-            if (scrollable)
-            {
-                // (Move up and down to reveal more choices)
-                list.Add(_moreChoices);
-            }
-
-            // (Press <space> to select)
-            list.Add(_instructions);
-
-            return new Rows(list);
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs b/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs
deleted file mode 100644
index 552aab5..0000000
--- a/src/Spectre.Console/Widgets/Prompt/Rendering/RenderableSelectionList.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Spectre.Console.Rendering;
-
-namespace Spectre.Console
-{
-    internal sealed class RenderableSelectionList<T> : RenderableList<T>
-    {
-        private const string Prompt = ">";
-        private const string MoreChoicesText = "[grey](Move up and down to reveal more choices)[/]";
-
-        private readonly IAnsiConsole _console;
-        private readonly string? _title;
-        private readonly Markup _moreChoices;
-        private readonly Style _highlightStyle;
-
-        public RenderableSelectionList(
-            IAnsiConsole console, string? title, int requestedPageSize,
-            List<T> choices, Func<T, string>? converter, Style? highlightStyle,
-            string? moreChoices)
-            : base(console, requestedPageSize, choices, converter)
-        {
-            _console = console ?? throw new ArgumentNullException(nameof(console));
-            _title = title;
-            _highlightStyle = highlightStyle ?? new Style(foreground: Color.Blue);
-            _moreChoices = new Markup(moreChoices ?? MoreChoicesText);
-        }
-
-        protected override int CalculatePageSize(int requestedPageSize)
-        {
-            var pageSize = requestedPageSize;
-            if (pageSize > _console.Profile.Height - 4)
-            {
-                pageSize = _console.Profile.Height - 4;
-            }
-
-            return pageSize;
-        }
-
-        protected override IRenderable Build(int pointerIndex, bool scrollable, IEnumerable<(int Original, int Index, string Item)> choices)
-        {
-            var list = new List<IRenderable>();
-
-            if (_title != null)
-            {
-                list.Add(new Markup(_title));
-            }
-
-            var grid = new Grid();
-            grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap());
-            grid.AddColumn(new GridColumn().Padding(0, 0, 0, 0));
-
-            if (_title != null)
-            {
-                grid.AddEmptyRow();
-            }
-
-            foreach (var choice in choices)
-            {
-                var current = choice.Index == pointerIndex;
-
-                var prompt = choice.Index == pointerIndex ? Prompt : string.Empty;
-                var style = current ? _highlightStyle : Style.Plain;
-
-                var item = current
-                    ? new Text(choice.Item.RemoveMarkup(), style)
-                    : (IRenderable)new Markup(choice.Item, style);
-
-                grid.AddRow(new Markup(prompt, style), item);
-            }
-
-            list.Add(grid);
-
-            if (scrollable)
-            {
-                // (Move up and down to reveal more choices)
-                list.Add(Text.Empty);
-                list.Add(_moreChoices);
-            }
-
-            return new Rows(list);
-        }
-    }
-}
diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs b/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs
index 3c6672c..8fb1b63 100644
--- a/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs
+++ b/src/Spectre.Console/Widgets/Prompt/SelectionPrompt.cs
@@ -6,27 +6,19 @@ using Spectre.Console.Rendering;
 namespace Spectre.Console
 {
     /// <summary>
-    /// Represents a list prompt.
+    /// Represents a single list prompt.
     /// </summary>
     /// <typeparam name="T">The prompt result type.</typeparam>
-    public sealed class SelectionPrompt<T> : IPrompt<T>
+    public sealed class SelectionPrompt<T> : IPrompt<T>, IListPromptStrategy<T>
+        where T : notnull
     {
+        private readonly ListPromptTree<T> _tree;
+
         /// <summary>
         /// Gets or sets the title.
         /// </summary>
         public string? Title { get; set; }
 
-        /// <summary>
-        /// Gets the choices.
-        /// </summary>
-        public List<T> Choices { get; }
-
-        /// <summary>
-        /// Gets or sets the converter to get the display string for a choice. By default
-        /// the corresponding <see cref="TypeConverter"/> is used.
-        /// </summary>
-        public Func<T, string>? Converter { get; set; } = TypeConverterHelper.ConvertToString;
-
         /// <summary>
         /// Gets or sets the page size.
         /// Defaults to <c>10</c>.
@@ -38,68 +30,151 @@ namespace Spectre.Console
         /// </summary>
         public Style? HighlightStyle { get; set; }
 
+        /// <summary>
+        /// Gets or sets the style of a disabled choice.
+        /// </summary>
+        public Style? DisabledStyle { get; set; }
+
+        /// <summary>
+        /// Gets or sets the converter to get the display string for a choice. By default
+        /// the corresponding <see cref="TypeConverter"/> is used.
+        /// </summary>
+        public Func<T, string>? Converter { get; set; }
+
         /// <summary>
         /// Gets or sets the text that will be displayed if there are more choices to show.
         /// </summary>
         public string? MoreChoicesText { get; set; }
 
+        /// <summary>
+        /// Gets or sets the selection mode.
+        /// Defaults to <see cref="SelectionMode.Leaf"/>.
+        /// </summary>
+        public SelectionMode Mode { get; set; } = SelectionMode.Leaf;
+
         /// <summary>
         /// Initializes a new instance of the <see cref="SelectionPrompt{T}"/> class.
         /// </summary>
         public SelectionPrompt()
         {
-            Choices = new List<T>();
+            _tree = new ListPromptTree<T>();
+        }
+
+        /// <summary>
+        /// Adds a choice.
+        /// </summary>
+        /// <param name="item">The item to add.</param>
+        /// <returns>A <see cref="ISelectionItem{T}"/> so that multiple calls can be chained.</returns>
+        public ISelectionItem<T> AddChoice(T item)
+        {
+            var node = new ListPromptItem<T>(item);
+            _tree.Add(node);
+            return node;
         }
 
         /// <inheritdoc/>
-        T IPrompt<T>.Show(IAnsiConsole console)
+        public T Show(IAnsiConsole console)
         {
-            if (!console.Profile.Capabilities.Interactive)
-            {
-                throw new NotSupportedException(
-                    "Cannot show selection prompt since the current " +
-                    "terminal isn't interactive.");
-            }
+            // Create the list prompt
+            var prompt = new ListPrompt<T>(console, this);
+            var result = prompt.Show(_tree);
 
-            if (!console.Profile.Capabilities.Ansi)
-            {
-                throw new NotSupportedException(
-                    "Cannot show selection prompt since the current " +
-                    "terminal does not support ANSI escape sequences.");
-            }
+            // Return the selected item
+            return result.Items[result.Index].Data;
+        }
 
-            return console.RunExclusive(() =>
+        /// <inheritdoc/>
+        ListPromptInputResult IListPromptStrategy<T>.HandleInput(ConsoleKeyInfo key, ListPromptState<T> state)
+        {
+            if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar)
             {
-                var converter = Converter ?? TypeConverterHelper.ConvertToString;
-                var list = new RenderableSelectionList<T>(
-                    console, Title, PageSize, Choices,
-                    converter, HighlightStyle, MoreChoicesText);
-
-                using (new RenderHookScope(console, list))
+                // Selecting a non leaf in "leaf mode" is not allowed
+                if (state.Current.IsGroup && Mode == SelectionMode.Leaf)
                 {
-                    console.Cursor.Hide();
-                    list.Redraw();
-
-                    while (true)
-                    {
-                        var key = console.Input.ReadKey(true);
-                        if (key.Key == ConsoleKey.Enter || key.Key == ConsoleKey.Spacebar)
-                        {
-                            break;
-                        }
-
-                        if (list.Update(key.Key))
-                        {
-                            list.Redraw();
-                        }
-                    }
+                    return ListPromptInputResult.None;
                 }
 
-                list.Clear();
-                console.Cursor.Show();
+                return ListPromptInputResult.Submit;
+            }
 
-                return Choices[list.Index];
-            });
+            return ListPromptInputResult.None;
+        }
+
+        /// <inheritdoc/>
+        int IListPromptStrategy<T>.CalculatePageSize(IAnsiConsole console, int totalItemCount, int requestedPageSize)
+        {
+            var extra = 0;
+
+            if (Title != null)
+            {
+                // Title takes up two rows including a blank line
+                extra += 2;
+            }
+
+            // Scrolling?
+            if (totalItemCount > requestedPageSize)
+            {
+                // The scrolling instructions takes up two rows
+                extra += 2;
+            }
+
+            if (requestedPageSize > console.Profile.Height - extra)
+            {
+                return console.Profile.Height - extra;
+            }
+
+            return requestedPageSize;
+        }
+
+        /// <inheritdoc/>
+        IRenderable IListPromptStrategy<T>.Render(IAnsiConsole console, bool scrollable, int cursorIndex, IEnumerable<(int Index, ListPromptItem<T> Node)> items)
+        {
+            var list = new List<IRenderable>();
+            var disabledStyle = DisabledStyle ?? new Style(foreground: Color.Grey);
+            var highlightStyle = HighlightStyle ?? new Style(foreground: Color.Blue);
+
+            if (Title != null)
+            {
+                list.Add(new Markup(Title));
+            }
+
+            var grid = new Grid();
+            grid.AddColumn(new GridColumn().Padding(0, 0, 1, 0).NoWrap());
+
+            if (Title != null)
+            {
+                grid.AddEmptyRow();
+            }
+
+            foreach (var item in items)
+            {
+                var current = item.Index == cursorIndex;
+                var prompt = item.Index == cursorIndex ? ListPromptConstants.Arrow : new string(' ', ListPromptConstants.Arrow.Length);
+                var style = item.Node.IsGroup && Mode == SelectionMode.Leaf
+                    ? disabledStyle
+                    : current ? highlightStyle : Style.Plain;
+
+                var indent = new string(' ', item.Node.Depth * 2);
+
+                var text = (Converter ?? TypeConverterHelper.ConvertToString)?.Invoke(item.Node.Data) ?? item.Node.Data.ToString() ?? "?";
+                if (current)
+                {
+                    text = text.RemoveMarkup();
+                }
+
+                grid.AddRow(new Markup(indent + prompt + " " + text, style));
+            }
+
+            list.Add(grid);
+
+            if (scrollable)
+            {
+                // (Move up and down to reveal more choices)
+                list.Add(Text.Empty);
+                list.Add(new Markup(MoreChoicesText ?? ListPromptConstants.MoreChoicesMarkup));
+            }
+
+            return new Rows(list);
         }
     }
 }
diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs b/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs
index 80d1f26..bcd2117 100644
--- a/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs
+++ b/src/Spectre.Console/Widgets/Prompt/SelectionPromptExtensions.cs
@@ -9,20 +9,21 @@ namespace Spectre.Console
     public static class SelectionPromptExtensions
     {
         /// <summary>
-        /// Adds a choice.
+        /// Sets the selection mode.
         /// </summary>
         /// <typeparam name="T">The prompt result type.</typeparam>
         /// <param name="obj">The prompt.</param>
-        /// <param name="choice">The choice to add.</param>
+        /// <param name="mode">The selection mode.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
-        public static SelectionPrompt<T> AddChoice<T>(this SelectionPrompt<T> obj, T choice)
+        public static SelectionPrompt<T> Mode<T>(this SelectionPrompt<T> obj, SelectionMode mode)
+            where T : notnull
         {
             if (obj is null)
             {
                 throw new ArgumentNullException(nameof(obj));
             }
 
-            obj.Choices.Add(choice);
+            obj.Mode = mode;
             return obj;
         }
 
@@ -34,13 +35,18 @@ namespace Spectre.Console
         /// <param name="choices">The choices to add.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> AddChoices<T>(this SelectionPrompt<T> obj, params T[] choices)
+            where T : notnull
         {
             if (obj is null)
             {
                 throw new ArgumentNullException(nameof(obj));
             }
 
-            obj.Choices.AddRange(choices);
+            foreach (var choice in choices)
+            {
+                obj.AddChoice(choice);
+            }
+
             return obj;
         }
 
@@ -52,13 +58,43 @@ namespace Spectre.Console
         /// <param name="choices">The choices to add.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> AddChoices<T>(this SelectionPrompt<T> obj, IEnumerable<T> choices)
+            where T : notnull
         {
             if (obj is null)
             {
                 throw new ArgumentNullException(nameof(obj));
             }
 
-            obj.Choices.AddRange(choices);
+            foreach (var choice in choices)
+            {
+                obj.AddChoice(choice);
+            }
+
+            return obj;
+        }
+
+        /// <summary>
+        /// Adds multiple grouped choices.
+        /// </summary>
+        /// <typeparam name="T">The prompt result type.</typeparam>
+        /// <param name="obj">The prompt.</param>
+        /// <param name="group">The group.</param>
+        /// <param name="choices">The choices to add.</param>
+        /// <returns>The same instance so that multiple calls can be chained.</returns>
+        public static SelectionPrompt<T> AddChoiceGroup<T>(this SelectionPrompt<T> obj, T group, IEnumerable<T> choices)
+            where T : notnull
+        {
+            if (obj is null)
+            {
+                throw new ArgumentNullException(nameof(obj));
+            }
+
+            var root = obj.AddChoice(group);
+            foreach (var choice in choices)
+            {
+                root.AddChild(choice);
+            }
+
             return obj;
         }
 
@@ -70,6 +106,7 @@ namespace Spectre.Console
         /// <param name="title">The title markup text.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> Title<T>(this SelectionPrompt<T> obj, string? title)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -88,6 +125,7 @@ namespace Spectre.Console
         /// <param name="pageSize">The number of choices that are displayed to the user.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> PageSize<T>(this SelectionPrompt<T> obj, int pageSize)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -111,6 +149,7 @@ namespace Spectre.Console
         /// <param name="highlightStyle">The highlight style of the selected choice.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> HighlightStyle<T>(this SelectionPrompt<T> obj, Style highlightStyle)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -129,6 +168,7 @@ namespace Spectre.Console
         /// <param name="text">The text to display.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> MoreChoicesText<T>(this SelectionPrompt<T> obj, string? text)
+            where T : notnull
         {
             if (obj is null)
             {
@@ -147,6 +187,7 @@ namespace Spectre.Console
         /// <param name="displaySelector">The function to get a display string for a given choice.</param>
         /// <returns>The same instance so that multiple calls can be chained.</returns>
         public static SelectionPrompt<T> UseConverter<T>(this SelectionPrompt<T> obj, Func<T, string>? displaySelector)
+            where T : notnull
         {
             if (obj is null)
             {
diff --git a/src/Spectre.Console/Widgets/Prompt/SelectionType.cs b/src/Spectre.Console/Widgets/Prompt/SelectionType.cs
new file mode 100644
index 0000000..963888a
--- /dev/null
+++ b/src/Spectre.Console/Widgets/Prompt/SelectionType.cs
@@ -0,0 +1,19 @@
+namespace Spectre.Console
+{
+    /// <summary>
+    /// Represents how selections are made in a hierarchical prompt.
+    /// </summary>
+    public enum SelectionMode
+    {
+        /// <summary>
+        /// Will only return lead nodes in results.
+        /// </summary>
+        Leaf = 0,
+
+        /// <summary>
+        /// Allows selection of parent nodes, but each node
+        /// is independent of its parent and children.
+        /// </summary>
+        Independent = 1,
+    }
+}