feat(help): enhance CLI help rendering with Panels and TLDR validation

- Updated `Command.help_signature` to return a `(Panel, description)` tuple,
  enabling richer Rich-based help output with formatted panels.
- Integrated `program` context into commands to display accurate CLI invocation
  (`falyx run …`) depending on mode (RUN, PREVIEW, RUN_ALL).
- Refactored `_show_help` to print `Panel`-styled usage and descriptions instead
  of table rows.
- Added `program` and `options_manager` propagation to built-in commands
  (Exit, History, Help) for consistent CLI display.
- Improved `CommandArgumentParser.add_tldr_examples()` with stricter validation
  (`all()` instead of `any()`), and added new TLDR tests for coverage.
- Simplified parser epilog text to `Tip: Use 'falyx run ?' to show available commands.`
- Added tests for required `Argument` fields and TLDR examples.
- Bumped version to 0.1.73.
This commit is contained in:
2025-07-26 16:14:09 -04:00
parent 734f7b5962
commit 7dca416346
8 changed files with 137 additions and 48 deletions

View File

@ -22,6 +22,8 @@ from typing import Any, Awaitable, Callable
from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.formatted_text import FormattedText
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.padding import Padding
from rich.panel import Panel
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import Action from falyx.action.action import Action
@ -32,6 +34,7 @@ from falyx.debug import register_debug_hooks
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parser.command_argument_parser import CommandArgumentParser from falyx.parser.command_argument_parser import CommandArgumentParser
from falyx.parser.signature import infer_args_from_func from falyx.parser.signature import infer_args_from_func
@ -348,20 +351,37 @@ class Command(BaseModel):
return f" {command_keys_text:<20} {options_text} " return f" {command_keys_text:<20} {options_text} "
@property @property
def help_signature(self) -> str: def help_signature(self) -> tuple[Panel, str]:
"""Generate a help signature for the command.""" """Generate a help signature for the command."""
is_cli_mode = self.options_manager.get("mode") in {
FalyxMode.RUN,
FalyxMode.PREVIEW,
FalyxMode.RUN_ALL,
}
program = f"{self.program} run " if is_cli_mode else ""
if self.arg_parser and not self.simple_help_signature: if self.arg_parser and not self.simple_help_signature:
signature = [self.arg_parser.get_usage()] usage = Panel(
signature.append(f" {self.help_text or self.description}") f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}",
expand=False,
)
description = [f" {self.help_text or self.description}"]
if self.tags: if self.tags:
signature.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]") description.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
return "\n".join(signature).strip() return usage, "\n".join(description)
command_keys = " | ".join( command_keys = " | ".join(
[f"[{self.style}]{self.key}[/{self.style}]"] [f"[{self.style}]{self.key}[/{self.style}]"]
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
) )
return f"{command_keys} {self.description}" return (
Panel(
f"[{self.style}]{program}[/]{command_keys} {self.description}",
expand=False,
),
"",
)
def log_summary(self) -> None: def log_summary(self) -> None:
if self._context: if self._context:

View File

@ -125,6 +125,7 @@ class Falyx:
title: str | Markdown = "Menu", title: str | Markdown = "Menu",
*, *,
program: str | None = "falyx", program: str | None = "falyx",
program_style: str = OneColors.WHITE,
usage: str | None = None, usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = None, epilog: str | None = None,
@ -158,13 +159,6 @@ class Falyx:
self.prompt: str | StyleAndTextTuples = rich_text_to_prompt_text(prompt) self.prompt: str | StyleAndTextTuples = rich_text_to_prompt_text(prompt)
self.columns: int = columns self.columns: int = columns
self.commands: dict[str, Command] = CaseInsensitiveDict() self.commands: dict[str, Command] = CaseInsensitiveDict()
self.exit_command: Command = self._get_exit_command()
self.history_command: Command | None = (
self._get_history_command() if include_history_command else None
)
self.help_command: Command | None = (
self._get_help_command() if include_help_command else None
)
self.console: Console = console self.console: Console = console
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
self.exit_message: str | Markdown | dict[str, Any] = exit_message self.exit_message: str | Markdown | dict[str, Any] = exit_message
@ -182,6 +176,13 @@ class Falyx:
self.validate_options(cli_args, options) self.validate_options(cli_args, options)
self._prompt_session: PromptSession | None = None self._prompt_session: PromptSession | None = None
self.options.set("mode", FalyxMode.MENU) self.options.set("mode", FalyxMode.MENU)
self.exit_command: Command = self._get_exit_command()
self.history_command: Command | None = (
self._get_history_command() if include_history_command else None
)
self.help_command: Command | None = (
self._get_help_command() if include_help_command else None
)
def validate_options( def validate_options(
self, self,
@ -262,6 +263,8 @@ class Falyx:
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
simple_help_signature=True, simple_help_signature=True,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options,
program=self.program,
) )
def _get_history_command(self) -> Command: def _get_history_command(self) -> Command:
@ -271,6 +274,7 @@ class Falyx:
command_description="History", command_description="History",
command_style=OneColors.DARK_YELLOW, command_style=OneColors.DARK_YELLOW,
aliases=["HISTORY"], aliases=["HISTORY"],
program=self.program,
) )
parser.add_argument( parser.add_argument(
"-n", "-n",
@ -314,17 +318,19 @@ class Falyx:
arg_parser=parser, arg_parser=parser,
help_text="View the execution history of commands.", help_text="View the execution history of commands.",
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options,
program=self.program,
) )
async def _show_help(self, tag: str = "") -> None: async def _show_help(self, tag: str = "") -> None:
is_cli_mode = self.options.get("mode") in {
FalyxMode.RUN,
FalyxMode.PREVIEW,
FalyxMode.RUN_ALL,
}
program = f"{self.program} run " if is_cli_mode else ""
if tag: if tag:
table = Table(
title=tag.upper(),
title_justify="left",
show_header=False,
box=box.SIMPLE,
show_footer=False,
)
tag_lower = tag.lower() tag_lower = tag.lower()
commands = [ commands = [
command command
@ -332,27 +338,31 @@ class Falyx:
if any(tag_lower == tag.lower() for tag in command.tags) if any(tag_lower == tag.lower() for tag in command.tags)
] ]
for command in commands: for command in commands:
table.add_row(command.help_signature) usage, description = command.help_signature
self.console.print(table) self.console.print(usage)
if description:
self.console.print(description)
return return
else:
table = Table( for command in self.commands.values():
title="Help", usage, description = command.help_signature
title_justify="left", self.console.print(usage)
title_style=OneColors.LIGHT_YELLOW_b, if description:
show_header=False, self.console.print(description)
show_footer=False,
box=box.SIMPLE,
)
for command in self.commands.values():
table.add_row(command.help_signature)
if self.help_command: if self.help_command:
table.add_row(self.help_command.help_signature) usage, description = self.help_command.help_signature
if self.history_command: self.console.print(usage)
table.add_row(self.history_command.help_signature) self.console.print(description)
table.add_row(self.exit_command.help_signature) if not is_cli_mode:
table.add_row(f"Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command ") if self.history_command:
self.console.print(table) usage, description = self.history_command.help_signature
self.console.print(usage)
self.console.print(description)
usage, _ = self.exit_command.help_signature
self.console.print(usage)
self.console.print(
f"Tip: '{program}[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
)
def _get_help_command(self) -> Command: def _get_help_command(self) -> Command:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
@ -361,6 +371,7 @@ class Falyx:
command_description="Help", command_description="Help",
command_style=OneColors.LIGHT_YELLOW, command_style=OneColors.LIGHT_YELLOW,
aliases=["?", "HELP", "LIST"], aliases=["?", "HELP", "LIST"],
program=self.program,
) )
parser.add_argument( parser.add_argument(
"-t", "-t",
@ -378,6 +389,8 @@ class Falyx:
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser, arg_parser=parser,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options,
program=self.program,
) )
def _get_completer(self) -> FalyxCompleter: def _get_completer(self) -> FalyxCompleter:
@ -549,6 +562,8 @@ class Falyx:
confirm=confirm, confirm=confirm,
confirm_message=confirm_message, confirm_message=confirm_message,
ignore_in_history=True, ignore_in_history=True,
options_manager=self.options,
program=self.program,
) )
def add_submenu( def add_submenu(
@ -729,7 +744,7 @@ class Falyx:
def build_placeholder_menu(self) -> StyleAndTextTuples: def build_placeholder_menu(self) -> StyleAndTextTuples:
""" """
Builds a menu placeholder for prompt_menu mode. Builds a menu placeholder for show_placeholder_menu.
""" """
visible_commands = [item for item in self.commands.items() if not item[1].hidden] visible_commands = [item for item in self.commands.items() if not item[1].hidden]
if not visible_commands: if not visible_commands:

View File

@ -142,12 +142,13 @@ class CommandArgumentParser:
Args: Args:
examples (list[tuple[str, str]]): List of (usage, description) tuples. examples (list[tuple[str, str]]): List of (usage, description) tuples.
""" """
if not any( if not all(
isinstance(example, tuple) and len(example) == 2 for example in examples isinstance(example, tuple) and len(example) == 2 for example in examples
): ):
raise CommandArgumentError( raise CommandArgumentError(
"TLDR examples must be a list of (usage, description) tuples" "TLDR examples must be a list of (usage, description) tuples"
) )
for usage, description in examples: for usage, description in examples:
self._tldr_examples.append(TLDRExample(usage=usage, description=description)) self._tldr_examples.append(TLDRExample(usage=usage, description=description))
@ -1362,7 +1363,7 @@ class CommandArgumentParser:
command = self.aliases[0] if self.aliases else self.command_key command = self.aliases[0] if self.aliases else self.command_key
if is_cli_mode: if is_cli_mode:
command = ( command = (
f"{program} run [{self.command_style}]{command}[/{self.command_style}]" f"[{self.command_style}]{program} run {command}[/{self.command_style}]"
) )
else: else:
command = f"[{self.command_style}]{command}[/{self.command_style}]" command = f"[{self.command_style}]{command}[/{self.command_style}]"

View File

@ -59,9 +59,7 @@ def get_root_parser(
prog: str | None = "falyx", prog: str | None = "falyx",
usage: str | None = None, usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: ( epilog: str | None = "Tip: Use 'falyx run ?' to show available commands.",
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None, parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-", prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None, fromfile_prefix_chars: str | None = None,
@ -242,7 +240,7 @@ def get_arg_parsers(
- Use `falyx run ?[COMMAND]` from the CLI to preview a command. - Use `falyx run ?[COMMAND]` from the CLI to preview a command.
""" """
if epilog is None: if epilog is None:
epilog = f"Tip: Use '{prog} run ?[COMMAND]' to preview any command from the CLI." epilog = f"Tip: Use '{prog} run ?' to show available commands."
if root_parser is None: if root_parser is None:
parser = get_root_parser( parser = get_root_parser(
prog=prog, prog=prog,

View File

@ -1 +1 @@
__version__ = "0.1.72" __version__ = "0.1.73"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.72" version = "0.1.73"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"

View File

@ -88,3 +88,11 @@ def test_argument_equality():
assert arg != "not an argument" assert arg != "not an argument"
assert arg is not None assert arg is not None
assert arg != object() assert arg != object()
def test_argument_required():
arg = Argument("--foo", dest="foo", required=True)
assert arg.required is True
arg2 = Argument("--bar", dest="bar", required=False)
assert arg2.required is False

View File

@ -0,0 +1,47 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parser.command_argument_parser import CommandArgumentParser
@pytest.mark.asyncio
async def test_add_tldr_examples():
parser = CommandArgumentParser()
parser.add_tldr_examples(
[
("example1", "This is the first example."),
("example2", "This is the second example."),
]
)
assert len(parser._tldr_examples) == 2
assert parser._tldr_examples[0].usage == "example1"
assert parser._tldr_examples[0].description == "This is the first example."
assert parser._tldr_examples[1].usage == "example2"
assert parser._tldr_examples[1].description == "This is the second example."
@pytest.mark.asyncio
async def test_bad_tldr_examples():
parser = CommandArgumentParser()
with pytest.raises(CommandArgumentError):
parser.add_tldr_examples(
[
("example1", "This is the first example.", "extra_arg"),
("example2", "This is the second example."),
]
)
@pytest.mark.asyncio
async def test_add_tldr_examples_in_init():
parser = CommandArgumentParser(
tldr_examples=[
("example1", "This is the first example."),
("example2", "This is the second example."),
]
)
assert len(parser._tldr_examples) == 2
assert parser._tldr_examples[0].usage == "example1"
assert parser._tldr_examples[0].description == "This is the first example."
assert parser._tldr_examples[1].usage == "example2"
assert parser._tldr_examples[1].description == "This is the second example."