Skip to content

Commit 42f35a4

Browse files
committed
feat(cli): add revela info command tree with TUI inlining (#23)
Adds three new diagnostic commands plus the inlining mechanism that lets a parent command render flat in the interactive menu while keeping a clean nested CLI surface. Commands: revela info Revela summary panel (default action) revela info plugins Lists installed plugins revela info plugins <name> Plugin detail (Phase 2, plugin-provided) revela info themes Lists installed themes (active marker) revela info themes <name> Theme detail (Phase 2, theme-provided) TUI rendering: Info Revela ← virtual default-action entry Plugins → ← only shown when plugins register sub-subcommands Themes → ← only shown when themes register sub-subcommands > Exit The menu group is registered with order 90 (bottom of main menu, above Exit). Plugins/themes contribute per-package detail subcommands via the existing multi-level `ParentCommand` mechanism ("info plugins" or "info themes") — no SDK API change required. Mechanism (CommandDescriptor + CommandOrderRegistry + MenuChoice): - New opt-in fields `InlineInMenu` and `InlineDefaultActionLabel` on CommandDescriptor (default false → zero impact on existing descriptors). - InteractiveMenuService.BuildGroupedSelectionPrompt expands inlined parents into a virtual default-action entry plus visible sub- subcommands, each with absolute path overrides so CLI dispatch remains correct. - Inlined subcommands without nested children are filtered out — a leaf entry with no extension provides no menu value beyond the parent's default action. Bug fix bundled in: ShowMainMenuAsync had its own dispatch switch that hardcoded `[selection.Command.Name]` for the args path, ignoring CommandPathOverride from inlined entries. Clicking an inlined subcommand at top level produced `Unrecognized command or argument` from System.CommandLine. The top-level menu now routes through the same HandleMenuActionAsync as nested menus, honoring the override. Plugin convention documented in .github/instructions/plugins.instructions.md: info subcommands are read-only diagnostics (no prompts, compact output suitable for bug-report copy-paste, safe without project).
1 parent 9db74fa commit 42f35a4

15 files changed

Lines changed: 688 additions & 16 deletions

.github/instructions/plugins.instructions.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,37 @@ public sealed class MyFeaturePlugin : IPlugin
5656
| Param | Meaning |
5757
|-------|---------|
5858
| `Command` | The `System.CommandLine.Command` instance (from `MyCommand.Create()`) |
59-
| `ParentCommand` | `null` = root level, `"source"`/`"generate"`/etc. = subcommand. Parent created automatically if missing. |
59+
| `ParentCommand` | `null` = root level, `"source"`/`"generate"`/etc. = subcommand. Parent created automatically if missing. **Multi-level paths supported** (`"info plugins"``revela info plugins <name>`). |
6060
| `Order` | Sort order within parent (default 50; lower = earlier) |
6161
| `Group` | Display group label in interactive menu |
6262
| `RequiresProject` | `false` = available without `project.json` (e.g. `init`, `setup`) |
6363
| `HideWhenProjectExists` | `true` = hidden inside a project (e.g. setup wizards) |
6464
| `IsSequentialStep` | `true` = picked up by CLI `generate all` discovery. Pair with `IPipelineStep` for engine/MCP. |
65+
| `InlineInMenu` | Host-only flag (`info` command tree). Plugins should not need this. |
66+
| `InlineDefaultActionLabel` | Required when `InlineInMenu = true`. Plugins should not need this. |
67+
68+
## `info` Subcommands — Convention for Plugins
69+
Plugins **may** contribute one read-only diagnostic subcommand under
70+
`revela info plugins <plugin-name>` by registering with
71+
`ParentCommand: "info plugins"`. This is opt-in; nothing breaks if you skip it.
72+
73+
```csharp
74+
yield return new CommandDescriptor(
75+
myInfoCommand.Create(),
76+
ParentCommand: "info plugins",
77+
Order: 10);
78+
```
79+
80+
Hard rules for `info` subcommands:
81+
- **Read-only.** No prompts, no writes, no network calls that mutate state.
82+
- **Compact.** Output sized for bug-report copy-paste — typically a single
83+
Spectre `Panel` with key/value lines. No tables that scroll.
84+
- **Fast.** No long-running work; user expects a tap-and-read response.
85+
- **Safe without context.** Must not crash when invoked without an active
86+
project (e.g. report "no project loaded" instead of throwing).
87+
- **No side effects on cache, auth, or files.** This is diagnostics, not
88+
troubleshooting tooling. Use a dedicated `doctor` or `check` command if
89+
you need active probing.
6590

6691
## Plugin Configuration
6792
1. Create config class with `[RevelaConfig("Spectara.Revela.Plugins.MyFeature")]` — full package ID is the JSON section name.

src/Cli/Hosting/CommandGroupRegistry.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ internal static class CommandGroups
1919
/// <summary>Plugin addons and optional features.</summary>
2020
public const string Addons = "Addons";
2121

22+
/// <summary>Diagnostic / about commands (main menu, bottom).</summary>
23+
public const string Info = "Info";
24+
2225
// Config submenu groups
2326

2427
/// <summary>Core project configuration commands (config submenu).</summary>

src/Cli/Hosting/CommandOrderRegistry.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal sealed class CommandOrderRegistry
2424
private readonly HashSet<Command> noProjectRequired = [];
2525
private readonly HashSet<Command> hideWhenProjectExists = [];
2626
private readonly HashSet<Command> pipelineSteps = [];
27+
private readonly Dictionary<Command, string> inlinedParents = [];
2728

2829
/// <summary>
2930
/// Registers the display order for a command.
@@ -86,6 +87,33 @@ internal sealed class CommandOrderRegistry
8687
/// <returns>True if the command is a pipeline step.</returns>
8788
public bool IsPipelineStep(Command command) => pipelineSteps.Contains(command);
8889

90+
/// <summary>
91+
/// Marks a command as an inlined parent for the interactive menu.
92+
/// </summary>
93+
/// <remarks>
94+
/// Inlined parents are not rendered as a single navigable entry. Instead
95+
/// the menu renders a virtual entry for the command's default action
96+
/// (labeled by <paramref name="defaultActionLabel"/>) and lists the
97+
/// command's visible subcommands directly under the same group label.
98+
/// CLI behavior is unaffected.
99+
/// </remarks>
100+
/// <param name="command">The parent command to render inline.</param>
101+
/// <param name="defaultActionLabel">Display label for the virtual default-action entry.</param>
102+
public void RegisterInlinedParent(Command command, string defaultActionLabel) =>
103+
inlinedParents[command] = defaultActionLabel;
104+
105+
/// <summary>
106+
/// Gets whether a command is registered as an inlined parent.
107+
/// </summary>
108+
public bool IsInlinedParent(Command command) => inlinedParents.ContainsKey(command);
109+
110+
/// <summary>
111+
/// Gets the display label for an inlined parent's default-action entry,
112+
/// or <c>null</c> if the command is not registered as inlined.
113+
/// </summary>
114+
public string? GetInlineDefaultActionLabel(Command command) =>
115+
inlinedParents.TryGetValue(command, out var label) ? label : null;
116+
89117
/// <summary>
90118
/// Gets the display order for a command.
91119
/// </summary>

src/Cli/Hosting/CoreCommandProvider.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using Spectara.Revela.Commands.Config;
3+
using Spectara.Revela.Commands.Info;
34
using Spectara.Revela.Features.Generate.Commands;
45
using Spectara.Revela.Features.Theme.Commands;
56
using Spectara.Revela.Sdk.Abstractions;
@@ -94,6 +95,28 @@ public IEnumerable<CommandDescriptor> GetCommands(IServiceProvider services)
9495
Group: CommandGroups.Addons,
9596
RequiresProject: false);
9697

98+
// ── Info group (TUI rendered inline: Revela / Plugins → / Themes →) ──
99+
var infoCommand = services.GetRequiredService<InfoCommand>();
100+
yield return new CommandDescriptor(
101+
infoCommand.Create(),
102+
Order: 10,
103+
Group: CommandGroups.Info,
104+
RequiresProject: false,
105+
InlineInMenu: true,
106+
InlineDefaultActionLabel: "Revela");
107+
108+
var infoPluginsCommand = services.GetRequiredService<InfoPluginsCommand>();
109+
yield return new CommandDescriptor(
110+
infoPluginsCommand.Create(),
111+
ParentCommand: "info",
112+
Order: 20);
113+
114+
var infoThemesCommand = services.GetRequiredService<InfoThemesCommand>();
115+
yield return new CommandDescriptor(
116+
infoThemesCommand.Create(),
117+
ParentCommand: "info",
118+
Order: 30);
119+
97120
// Restore, Plugin, and Packages commands are provided by PackagesCommandProvider
98121
// (only available in Cli, not in Cli.Embedded)
99122

src/Cli/Hosting/HostExtensions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public static RootCommand UseRevelaCommands(
6060
groupRegistry.Register(CommandGroups.Content, 20);
6161
groupRegistry.Register(CommandGroups.Setup, 30);
6262
groupRegistry.Register(CommandGroups.Addons, 40);
63+
groupRegistry.Register(CommandGroups.Info, 90);
6364

6465
// Create root command
6566
var rootCommand = new RootCommand(description);
@@ -95,6 +96,17 @@ void OnCommandRegistered(Command cmd, CommandDescriptor desc)
9596
pipelineOrderProvider.Register(desc.ParentCommand, cmd.Name, desc.Order);
9697
}
9798
}
99+
100+
if (desc.InlineInMenu)
101+
{
102+
if (string.IsNullOrEmpty(desc.InlineDefaultActionLabel))
103+
{
104+
throw new InvalidOperationException(
105+
$"Command '{cmd.Name}' has InlineInMenu=true but no InlineDefaultActionLabel. " +
106+
"Provide a label for the virtual default-action menu entry.");
107+
}
108+
orderRegistry.RegisterInlinedParent(cmd, desc.InlineDefaultActionLabel);
109+
}
98110
}
99111

100112
// Core commands (via CoreCommandProvider — uses same registration as plugins

src/Cli/Hosting/InteractiveMenuService.cs

Lines changed: 102 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public async Task<int> RunAsync(CancellationToken cancellationToken = default)
7272

7373
private async Task<int> HandleFirstRunAsync(CancellationToken cancellationToken)
7474
{
75-
ConsoleUI.ClearAndShowLogo();
75+
ConsoleUI.ClearConsole();
7676
ConsoleUI.ShowFirstRunPanel();
7777

7878
var wizard = setupWizards.FirstOrDefault();
@@ -111,7 +111,7 @@ private async Task<int> HandleFirstRunAsync(CancellationToken cancellationToken)
111111

112112
private async Task<int> HandleNoProjectAsync(CancellationToken cancellationToken)
113113
{
114-
ConsoleUI.ClearAndShowLogo();
114+
ConsoleUI.ClearConsole();
115115

116116
var folderName = projectEnvironment.Value.FolderName;
117117

@@ -175,7 +175,7 @@ private void ShowWelcomeBanner()
175175

176176
bannerShown = true;
177177

178-
ConsoleUI.ClearAndShowLogo();
178+
ConsoleUI.ClearConsole();
179179

180180
// Show project name if initialized, otherwise show folder name
181181
var projectName = projectConfig.CurrentValue.Name;
@@ -208,14 +208,15 @@ private async Task<MenuResult> ShowMainMenuAsync(CancellationToken cancellationT
208208

209209
var selection = AnsiConsole.Prompt(prompt);
210210

211+
// Top-level dispatch: Exit/Back/Wizard handled here; Navigate/Execute
212+
// delegate to HandleMenuActionAsync so CommandPathOverride from inlined
213+
// entries (e.g. "Plugins" → ["info","plugins"]) is honored.
211214
return selection.Action switch
212215
{
213216
MenuAction.Exit => new MenuResult(true, 0),
214217
MenuAction.Back => new MenuResult(false, 0),
215-
MenuAction.Navigate => await NavigateToCommandAsync(selection.Command!, [selection.Command!.Name], cancellationToken),
216-
MenuAction.Execute => await ExecuteCommandAsync(selection.Command!, [selection.Command!.Name], cancellationToken),
217218
MenuAction.RunSetupWizard => await RunSetupWizardAsync(cancellationToken),
218-
_ => new MenuResult(false, 0),
219+
MenuAction.Navigate or MenuAction.Execute or _ => await HandleMenuActionAsync(selection, [], cancellationToken),
219220
};
220221
}
221222

@@ -295,7 +296,9 @@ private async Task<MenuResult> HandleMenuActionAsync(
295296
return await RunSetupWizardAsync(cancellationToken);
296297
}
297298

298-
var extendedPath = new List<string>(commandPath) { selection.Command!.Name };
299+
var extendedPath = selection.CommandPathOverride is not null
300+
? [.. selection.CommandPathOverride]
301+
: new List<string>(commandPath) { selection.Command!.Name };
299302

300303
return selection.Action switch
301304
{
@@ -388,7 +391,7 @@ private SelectionPrompt<MenuChoice> BuildGroupedSelectionPrompt(
388391
// Calculate column width from longest command name (+ 2 for " →" arrow)
389392
var allCommands = grouped.SelectMany(g => g.Commands).ToList();
390393
var nameWidth = allCommands.Count > 0
391-
? allCommands.Max(c => c.Name.Length + (c.Subcommands.Any(s => !s.Hidden) ? 2 : 0)) + 2
394+
? allCommands.Max(c => MaxRenderedNameLength(c)) + 2
392395
: 0;
393396

394397
if (hasGroups)
@@ -406,7 +409,11 @@ private SelectionPrompt<MenuChoice> BuildGroupedSelectionPrompt(
406409
{
407410
// Create group header as MenuChoice (will be non-selectable due to Leaf mode)
408411
var groupChoice = new MenuChoice(groupName, Action: MenuAction.Navigate);
409-
var commandChoices = groupCommands.Select(c => MenuChoice.FromCommand(c, orderRegistry.IsPipelineStep(c), nameWidth)).ToList();
412+
var commandChoices = new List<MenuChoice>();
413+
foreach (var cmd in groupCommands)
414+
{
415+
AddGroupCommandChoices(commandChoices, cmd, nameWidth);
416+
}
410417

411418
// Add Wizard to the Addons group (as last item)
412419
if (includeSetupWizard && groupName == CommandGroups.Addons)
@@ -421,7 +428,7 @@ private SelectionPrompt<MenuChoice> BuildGroupedSelectionPrompt(
421428
// Ungrouped commands - add directly
422429
foreach (var cmd in groupCommands)
423430
{
424-
prompt.AddChoice(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth));
431+
AddUngroupedCommandChoices(prompt, cmd, nameWidth);
425432
}
426433
}
427434
}
@@ -444,6 +451,91 @@ private SelectionPrompt<MenuChoice> BuildGroupedSelectionPrompt(
444451
return prompt;
445452
}
446453

454+
/// <summary>
455+
/// Adds menu choices for a command appearing in a grouped section.
456+
/// Inlined parents are expanded into a virtual default-action entry plus
457+
/// each visible subcommand (with absolute path overrides).
458+
/// </summary>
459+
private void AddGroupCommandChoices(List<MenuChoice> choices, Command cmd, int nameWidth)
460+
{
461+
if (TryAddInlined(cmd, nameWidth, choices.Add))
462+
{
463+
return;
464+
}
465+
choices.Add(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth));
466+
}
467+
468+
/// <summary>
469+
/// Adds menu choices for an ungrouped command (rare).
470+
/// Inlined parents behave the same way as in grouped sections.
471+
/// </summary>
472+
private void AddUngroupedCommandChoices(SelectionPrompt<MenuChoice> prompt, Command cmd, int nameWidth)
473+
{
474+
if (TryAddInlined(cmd, nameWidth, choice => prompt.AddChoice(choice)))
475+
{
476+
return;
477+
}
478+
prompt.AddChoice(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth));
479+
}
480+
481+
private bool TryAddInlined(Command cmd, int nameWidth, Action<MenuChoice> add)
482+
{
483+
var label = orderRegistry.GetInlineDefaultActionLabel(cmd);
484+
if (label is null)
485+
{
486+
return false;
487+
}
488+
489+
// Virtual default-action entry: label runs the parent without subcommand
490+
add(MenuChoice.CreateInlinedDefaultAction(cmd, label, nameWidth));
491+
492+
// Each visible subcommand that itself has at least one visible sub-subcommand
493+
// (extension entries registered via ParentCommand: "<parent> <sub>").
494+
// Subcommands without any extension are skipped — the parent's default
495+
// action already covers their content (e.g. count/list summary).
496+
// Absolute path override (parent + sub) ensures correct CLI dispatch.
497+
foreach (var sub in cmd.Subcommands
498+
.Where(IsInlinableSub)
499+
.OrderBy(orderRegistry.GetOrder)
500+
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
501+
{
502+
add(MenuChoice.FromCommand(
503+
sub,
504+
orderRegistry.IsPipelineStep(sub),
505+
nameWidth,
506+
commandPathOverride: [cmd.Name, sub.Name]));
507+
}
508+
return true;
509+
}
510+
511+
private static bool IsInlinableSub(Command sub) =>
512+
!sub.Hidden && sub.Subcommands.Any(s => !s.Hidden);
513+
514+
/// <summary>
515+
/// Computes the rendered name length used for column alignment, accounting
516+
/// for the inline expansion of parents into a default-action label plus
517+
/// subcommand entries (each with the " →" arrow when navigable).
518+
/// </summary>
519+
private int MaxRenderedNameLength(Command cmd)
520+
{
521+
var label = orderRegistry.GetInlineDefaultActionLabel(cmd);
522+
if (label is null)
523+
{
524+
return cmd.Name.Length + (cmd.Subcommands.Any(s => !s.Hidden) ? 2 : 0);
525+
}
526+
527+
var max = label.Length;
528+
foreach (var sub in cmd.Subcommands.Where(IsInlinableSub))
529+
{
530+
var len = sub.Name.Length + 2; // always navigable when shown
531+
if (len > max)
532+
{
533+
max = len;
534+
}
535+
}
536+
return max;
537+
}
538+
447539
[LoggerMessage(Level = LogLevel.Error, Message = "RootCommand not set")]
448540
private static partial void LogRootCommandNotSet(ILogger logger);
449541

0 commit comments

Comments
 (0)