diff --git a/FileDeduplicator.Test/MenuTests.cs b/FileDeduplicator.Test/MenuTests.cs
new file mode 100644
index 0000000..0de466c
--- /dev/null
+++ b/FileDeduplicator.Test/MenuTests.cs
@@ -0,0 +1,94 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.FileDeduplicator.Test;
+
+using DustInTheWind.ConsoleTools.Controls;
+using DustInTheWind.ConsoleTools.Controls.Menus;
+
+using ktsu.FileDeduplicator.Verbs;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests the interactive menu that runs when no verb is given.
+///
+///
+/// Input is always redirected here, so these drive the numbered prompt. That prompt is also the
+/// menu on Linux and macOS, where the scrolling menu crashed reading Console.CursorVisible
+/// (ktsu-dev/FileDeduplicator#161).
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class MenuTests
+{
+ [TestMethod]
+ public void MenuListsTheVerbsAndExitsOnZero()
+ {
+ string output = ConsoleCapture.Run(new Menu(), "0\n", out int exitCode);
+
+ Assert.AreEqual(0, exitCode, output);
+ Assert.Contains("Scan - ", output);
+ Assert.Contains("Deduplicate - ", output);
+ Assert.Contains("0. Exit", output);
+ Assert.DoesNotContain("Menu", output, "The menu offered itself.");
+ }
+
+ [TestMethod]
+ public void MenuExitsAtTheEndOfInput()
+ {
+ _ = ConsoleCapture.Run(new Menu(), string.Empty, out int exitCode);
+
+ Assert.AreEqual(0, exitCode);
+ }
+
+ [TestMethod]
+ public void MenuRunsTheChosenVerbAndOffersTheMenuAgain()
+ {
+ // Arrange
+ using TempTree tree = new();
+ _ = tree.Write("a.txt", "alpha");
+ _ = tree.Write("aa.txt", "alpha");
+ Type[] menuVerbs = [.. Program.Verbs.Where(t => t != typeof(Menu))];
+ int scan = Array.IndexOf(menuVerbs, typeof(Scan)) + 1;
+ Assert.IsGreaterThan(0, scan, "Scan is not on the menu.");
+
+ // Act
+ string output = ConsoleCapture.Run(new Menu(), $"{scan}\n{tree.Root.WeakString}\n0\n", out int exitCode);
+
+ // Assert
+ Assert.AreEqual(0, exitCode, output);
+ Assert.Contains("Found 1 group(s) of duplicate files", output);
+ Assert.AreEqual(2, output.Split("0. Exit").Length - 1, "The menu was not offered again after the verb ran.");
+ }
+
+ [TestMethod]
+ public void MenuRejectsAChoiceThatIsNotOnIt()
+ {
+ string output = ConsoleCapture.Run(new Menu(), "42\n0\n", out int exitCode);
+
+ Assert.AreEqual(0, exitCode, output);
+ Assert.Contains("Not an option: 42", output);
+ }
+
+ ///
+ /// The scrolling menu used in an interactive Windows console has an Exit item whose command ends
+ /// the display loop, so it can be left without Ctrl+C.
+ ///
+ [TestMethod]
+ public void ScrollMenuHasAnExitItemThatClosesIt()
+ {
+ // Arrange
+ Menu menu = new();
+ ControlRepeater repeater = menu.BuildScrollMenu(out Menu.ExitCommand exit);
+
+ // Act
+ bool requestedBefore = exit.Requested;
+ exit.Execute();
+
+ // Assert
+ Assert.IsInstanceOfType(repeater.Control);
+ Assert.IsTrue(exit.IsActive);
+ Assert.IsFalse(requestedBefore);
+ Assert.IsTrue(exit.Requested);
+ }
+}
diff --git a/FileDeduplicator/Verbs/Menu.cs b/FileDeduplicator/Verbs/Menu.cs
index 4fdde2a..246af7d 100644
--- a/FileDeduplicator/Verbs/Menu.cs
+++ b/FileDeduplicator/Verbs/Menu.cs
@@ -18,6 +18,37 @@ internal sealed class Menu : BaseVerb