feat: add TLDR ArgumentAction and Rich-compatible prompt styling

- Introduce `ArgumentAction.TLDR` for showing concise usage examples
- Add `rich_text_to_prompt_text()` to support Rich-style markup in all prompt_toolkit inputs
- Migrate all prompt-based Actions to use `prompt_message` with Rich styling support
- Standardize `CancelSignal` as the default interrupt behavior for prompt-driven Actions
This commit is contained in:
2025-07-22 21:56:44 -04:00
parent de53c889a6
commit fa5e2a4c2c
20 changed files with 235 additions and 69 deletions

View File

@ -68,9 +68,16 @@ def default_config(parser: CommandArgumentParser) -> None:
type=int, type=int,
help="Optional number argument.", help="Optional number argument.",
) )
parser.add_tldr_examples(
[
("web", "Deploy 'web' to the default location (New York)"),
("cache London --tag beta", "Deploy 'cache' to London with tag"),
("database --region us-west-2 --verbose", "Verbose deploy to west region"),
]
)
flx = Falyx("Argument Examples") flx = Falyx("Argument Examples", program="argument_examples.py")
flx.add_command( flx.add_command(
key="T", key="T",

View File

@ -84,7 +84,7 @@ async def main() -> None:
# --- Bottom bar info --- # --- Bottom bar info ---
flx.bottom_bar.columns = 3 flx.bottom_bar.columns = 3
flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose") flx.bottom_bar.add_toggle_from_option("B", "Verbose", flx.options, "verbose")
flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks") flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks")
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}") flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")

View File

@ -51,7 +51,11 @@ from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.prompt_utils import (
confirm_async,
rich_text_to_prompt_text,
should_prompt_user,
)
from falyx.signals import CancelSignal from falyx.signals import CancelSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.validators import word_validator, words_validator from falyx.validators import word_validator, words_validator
@ -71,7 +75,7 @@ class ConfirmAction(BaseAction):
Attributes: Attributes:
name (str): Name of the action. Used for logging and debugging. name (str): Name of the action. Used for logging and debugging.
message (str): The confirmation message to display. prompt_message (str): The confirmation message to display.
confirm_type (ConfirmType | str): The type of confirmation to use. confirm_type (ConfirmType | str): The type of confirmation to use.
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
prompt_session (PromptSession | None): The session to use for input. prompt_session (PromptSession | None): The session to use for input.
@ -84,7 +88,7 @@ class ConfirmAction(BaseAction):
def __init__( def __init__(
self, self,
name: str, name: str,
message: str = "Confirm?", prompt_message: str = "Confirm?",
confirm_type: ConfirmType | str = ConfirmType.YES_NO, confirm_type: ConfirmType | str = ConfirmType.YES_NO,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
never_prompt: bool = False, never_prompt: bool = False,
@ -111,9 +115,11 @@ class ConfirmAction(BaseAction):
inject_into=inject_into, inject_into=inject_into,
never_prompt=never_prompt, never_prompt=never_prompt,
) )
self.message = message self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.confirm_type = ConfirmType(confirm_type) self.confirm_type = ConfirmType(confirm_type)
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.word = word self.word = word
self.return_last_result = return_last_result self.return_last_result = return_last_result
@ -122,7 +128,7 @@ class ConfirmAction(BaseAction):
match self.confirm_type: match self.confirm_type:
case ConfirmType.YES_NO: case ConfirmType.YES_NO:
return await confirm_async( return await confirm_async(
self.message, self.prompt_message,
prefix="", prefix="",
suffix=" [Y/n] > ", suffix=" [Y/n] > ",
session=self.prompt_session, session=self.prompt_session,
@ -130,7 +136,7 @@ class ConfirmAction(BaseAction):
case ConfirmType.YES_NO_CANCEL: case ConfirmType.YES_NO_CANCEL:
error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort." error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort."
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
f"{self.message} [Y]es, [N]o, or [C]ancel to abort > ", f"{self.prompt_message} [Y]es, [N]o, or [C]ancel to abort > ",
validator=words_validator( validator=words_validator(
["Y", "N", "C"], error_message=error_message ["Y", "N", "C"], error_message=error_message
), ),
@ -140,13 +146,13 @@ class ConfirmAction(BaseAction):
return answer.upper() == "Y" return answer.upper() == "Y"
case ConfirmType.TYPE_WORD: case ConfirmType.TYPE_WORD:
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
f"{self.message} [{self.word}] to confirm or [N/n] > ", f"{self.prompt_message} [{self.word}] to confirm or [N/n] > ",
validator=word_validator(self.word), validator=word_validator(self.word),
) )
return answer.upper().strip() != "N" return answer.upper().strip() != "N"
case ConfirmType.TYPE_WORD_CANCEL: case ConfirmType.TYPE_WORD_CANCEL:
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
f"{self.message} [{self.word}] to confirm or [N/n] > ", f"{self.prompt_message} [{self.word}] to confirm or [N/n] > ",
validator=word_validator(self.word), validator=word_validator(self.word),
) )
if answer.upper().strip() == "N": if answer.upper().strip() == "N":
@ -154,7 +160,7 @@ class ConfirmAction(BaseAction):
return answer.upper().strip() == self.word.upper().strip() return answer.upper().strip() == self.word.upper().strip()
case ConfirmType.YES_CANCEL: case ConfirmType.YES_CANCEL:
answer = await confirm_async( answer = await confirm_async(
self.message, self.prompt_message,
prefix="", prefix="",
suffix=" [Y/n] > ", suffix=" [Y/n] > ",
session=self.prompt_session, session=self.prompt_session,
@ -165,7 +171,7 @@ class ConfirmAction(BaseAction):
case ConfirmType.OK_CANCEL: case ConfirmType.OK_CANCEL:
error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort." error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort."
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
f"{self.message} [O]k to confirm, [C]ancel to abort > ", f"{self.prompt_message} [O]k to confirm, [C]ancel to abort > ",
validator=words_validator(["O", "C"], error_message=error_message), validator=words_validator(["O", "C"], error_message=error_message),
) )
if answer.upper() == "C": if answer.upper() == "C":
@ -173,7 +179,7 @@ class ConfirmAction(BaseAction):
return answer.upper() == "O" return answer.upper() == "O"
case ConfirmType.ACKNOWLEDGE: case ConfirmType.ACKNOWLEDGE:
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
f"{self.message} [A]cknowledge > ", f"{self.prompt_message} [A]cknowledge > ",
validator=word_validator("A"), validator=word_validator("A"),
) )
return answer.upper().strip() == "A" return answer.upper().strip() == "A"
@ -232,7 +238,7 @@ class ConfirmAction(BaseAction):
if not parent if not parent
else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}") else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}")
) )
tree.add(f"[bold]Message:[/] {self.message}") tree.add(f"[bold]Message:[/] {self.prompt_message}")
tree.add(f"[bold]Type:[/] {self.confirm_type.value}") tree.add(f"[bold]Type:[/] {self.confirm_type.value}")
tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}") tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}")
if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL): if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL):
@ -242,6 +248,6 @@ class ConfirmAction(BaseAction):
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f"ConfirmAction(name={self.name}, message={self.message}, " f"ConfirmAction(name={self.name}, message={self.prompt_message}, "
f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})" f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})"
) )

View File

@ -49,8 +49,9 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.menu import MenuOptionMap from falyx.menu import MenuOptionMap
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import prompt_for_selection, render_table_base from falyx.selection import prompt_for_selection, render_table_base
from falyx.signals import BackSignal, QuitSignal from falyx.signals import BackSignal, CancelSignal, QuitSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import chunks from falyx.utils import chunks
@ -134,9 +135,11 @@ class MenuAction(BaseAction):
self.menu_options = menu_options self.menu_options = menu_options
self.title = title self.title = title
self.columns = columns self.columns = columns
self.prompt_message = prompt_message self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.default_selection = default_selection self.default_selection = default_selection
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.include_reserved = include_reserved self.include_reserved = include_reserved
self.show_table = show_table self.show_table = show_table
self.custom_table = custom_table self.custom_table = custom_table

View File

@ -23,7 +23,8 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.menu import MenuOptionMap from falyx.menu import MenuOptionMap
from falyx.signals import BackSignal, QuitSignal from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.signals import BackSignal, CancelSignal, QuitSignal
from falyx.themes import OneColors from falyx.themes import OneColors
@ -96,9 +97,11 @@ class PromptMenuAction(BaseAction):
never_prompt=never_prompt, never_prompt=never_prompt,
) )
self.menu_options = menu_options self.menu_options = menu_options
self.prompt_message = prompt_message self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.default_selection = default_selection self.default_selection = default_selection
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.include_reserved = include_reserved self.include_reserved = include_reserved
def get_infer_target(self) -> tuple[None, None]: def get_infer_target(self) -> tuple[None, None]:

View File

@ -61,6 +61,7 @@ from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import ( from falyx.selection import (
SelectionOption, SelectionOption,
prompt_for_selection, prompt_for_selection,
@ -119,13 +120,15 @@ class SelectFileAction(BaseAction):
self.directory = Path(directory).resolve() self.directory = Path(directory).resolve()
self.title = title self.title = title
self.columns = columns self.columns = columns
self.prompt_message = prompt_message self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.suffix_filter = suffix_filter self.suffix_filter = suffix_filter
self.style = style self.style = style
self.number_selections = number_selections self.number_selections = number_selections
self.separator = separator self.separator = separator
self.allow_duplicates = allow_duplicates self.allow_duplicates = allow_duplicates
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.return_type = FileType(return_type) self.return_type = FileType(return_type)
self.encoding = encoding self.encoding = encoding

View File

@ -42,6 +42,7 @@ from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import ( from falyx.selection import (
SelectionOption, SelectionOption,
SelectionOptionMap, SelectionOptionMap,
@ -148,12 +149,14 @@ class SelectionAction(BaseAction):
self.return_type: SelectionReturnType = SelectionReturnType(return_type) self.return_type: SelectionReturnType = SelectionReturnType(return_type)
self.title = title self.title = title
self.columns = columns self.columns = columns
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.default_selection = default_selection self.default_selection = default_selection
self.number_selections = number_selections self.number_selections = number_selections
self.separator = separator self.separator = separator
self.allow_duplicates = allow_duplicates self.allow_duplicates = allow_duplicates
self.prompt_message = prompt_message self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.show_table = show_table self.show_table = show_table
@property @property

View File

@ -22,7 +22,7 @@ Use Cases:
Example: Example:
UserInputAction( UserInputAction(
name="GetUsername", name="GetUsername",
prompt_text="Enter your username > ", prompt_message="Enter your username > ",
validator=Validator.from_callable(lambda s: len(s) > 0), validator=Validator.from_callable(lambda s: len(s) > 0),
) )
""" """
@ -34,6 +34,8 @@ from falyx.action.base_action import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.signals import CancelSignal
from falyx.themes.colors import OneColors from falyx.themes.colors import OneColors
@ -47,7 +49,7 @@ class UserInputAction(BaseAction):
Args: Args:
name (str): Name of the action (used for introspection and logging). name (str): Name of the action (used for introspection and logging).
prompt_text (str): The prompt message shown to the user. prompt_message (str): The prompt message shown to the user.
Can include `{last_result}` if `inject_last_result=True`. Can include `{last_result}` if `inject_last_result=True`.
default_text (str): Optional default value shown in the prompt. default_text (str): Optional default value shown in the prompt.
validator (Validator | None): Prompt Toolkit validator for input constraints. validator (Validator | None): Prompt Toolkit validator for input constraints.
@ -59,7 +61,7 @@ class UserInputAction(BaseAction):
self, self,
name: str, name: str,
*, *,
prompt_text: str = "Input > ", prompt_message: str = "Input > ",
default_text: str = "", default_text: str = "",
validator: Validator | None = None, validator: Validator | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
@ -69,9 +71,11 @@ class UserInputAction(BaseAction):
name=name, name=name,
inject_last_result=inject_last_result, inject_last_result=inject_last_result,
) )
self.prompt_text = prompt_text self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.validator = validator self.validator = validator
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.default_text = default_text self.default_text = default_text
def get_infer_target(self) -> tuple[None, None]: def get_infer_target(self) -> tuple[None, None]:
@ -88,12 +92,12 @@ class UserInputAction(BaseAction):
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
prompt_text = self.prompt_text prompt_message = self.prompt_message
if self.inject_last_result and self.last_result: if self.inject_last_result and self.last_result:
prompt_text = prompt_text.format(last_result=self.last_result) prompt_message = prompt_message.format(last_result=self.last_result)
answer = await self.prompt_session.prompt_async( answer = await self.prompt_session.prompt_async(
prompt_text, prompt_message,
validator=self.validator, validator=self.validator,
default=kwargs.get("default_text", self.default_text), default=kwargs.get("default_text", self.default_text),
) )
@ -114,12 +118,12 @@ class UserInputAction(BaseAction):
label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'" label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label) tree = parent.add(label) if parent else Tree(label)
prompt_text = ( prompt_message = (
self.prompt_text.replace("{last_result}", "<last_result>") self.prompt_message.replace("{last_result}", "<last_result>")
if "{last_result}" in self.prompt_text if "{last_result}" in self.prompt_message
else self.prompt_text else self.prompt_message
) )
tree.add(f"[dim]Prompt:[/] {prompt_text}") tree.add(f"[dim]Prompt:[/] {prompt_message}")
if self.validator: if self.validator:
tree.add("[dim]Validator:[/] Yes") tree.add("[dim]Validator:[/] Yes")
if not parent: if not parent:

View File

@ -56,7 +56,7 @@ class BottomBar:
Must return True if key is available, otherwise False. Must return True if key is available, otherwise False.
""" """
RESERVED_CTRL_KEYS = {"c", "d", "z"} RESERVED_CTRL_KEYS = {"c", "d", "z", "v"}
def __init__( def __init__(
self, self,

View File

@ -98,6 +98,7 @@ class Command(BaseModel):
such as help text or choices. such as help text or choices.
simple_help_signature (bool): Whether to use a simplified help signature. simple_help_signature (bool): Whether to use a simplified help signature.
ignore_in_history (bool): Whether to ignore this command in execution history last result. ignore_in_history (bool): Whether to ignore this command in execution history last result.
program: (str | None): The parent program name.
Methods: Methods:
__call__(): Executes the command, respecting hooks and retries. __call__(): Executes the command, respecting hooks and retries.
@ -141,6 +142,7 @@ class Command(BaseModel):
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False simple_help_signature: bool = False
ignore_in_history: bool = False ignore_in_history: bool = False
program: str | None = None
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
@ -240,6 +242,8 @@ class Command(BaseModel):
help_text=self.help_text, help_text=self.help_text,
help_epilog=self.help_epilog, help_epilog=self.help_epilog,
aliases=self.aliases, aliases=self.aliases,
program=self.program,
options_manager=self.options_manager,
) )
for arg_def in self.get_argument_definitions(): for arg_def in self.get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)

View File

@ -26,12 +26,11 @@ import shlex
import sys import sys
from argparse import ArgumentParser, Namespace, _SubParsersAction from argparse import ArgumentParser, Namespace, _SubParsersAction
from difflib import get_close_matches from difflib import get_close_matches
from enum import Enum
from functools import cached_property from functools import cached_property
from typing import Any, Callable from typing import Any, Callable
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import AnyFormattedText from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.validation import ValidationError, Validator from prompt_toolkit.validation import ValidationError, Validator
@ -58,8 +57,10 @@ from falyx.exceptions import (
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import Hook, 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 import CommandArgumentParser, FalyxParsers, get_arg_parsers from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
@ -68,13 +69,6 @@ from falyx.utils import CaseInsensitiveDict, _noop, chunks, ensure_async
from falyx.version import __version__ from falyx.version import __version__
class FalyxMode(Enum):
MENU = "menu"
RUN = "run"
PREVIEW = "preview"
RUN_ALL = "run-all"
class CommandValidator(Validator): class CommandValidator(Validator):
"""Validator to check if the input is a valid command.""" """Validator to check if the input is a valid command."""
@ -167,7 +161,7 @@ class Falyx:
epilog: str | None = None, epilog: str | None = None,
version: str = __version__, version: str = __version__,
version_style: str = OneColors.BLUE_b, version_style: str = OneColors.BLUE_b,
prompt: str | AnyFormattedText = "> ", prompt: str | StyleAndTextTuples = "> ",
columns: int = 3, columns: int = 3,
bottom_bar: BottomBar | str | Callable[[], Any] | None = None, bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
welcome_message: str | Markdown | dict[str, Any] = "", welcome_message: str | Markdown | dict[str, Any] = "",
@ -191,7 +185,7 @@ class Falyx:
self.epilog: str | None = epilog self.epilog: str | None = epilog
self.version: str = version self.version: str = version
self.version_style: str = version_style self.version_style: str = version_style
self.prompt: str | AnyFormattedText = 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.exit_command: Command = self._get_exit_command()
@ -216,7 +210,7 @@ class Falyx:
self._hide_menu_table: bool = hide_menu_table self._hide_menu_table: bool = hide_menu_table
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.mode = FalyxMode.MENU self.options.set("mode", FalyxMode.MENU)
def validate_options( def validate_options(
self, self,
@ -702,6 +696,7 @@ class Falyx:
arg_metadata=arg_metadata or {}, arg_metadata=arg_metadata or {},
simple_help_signature=simple_help_signature, simple_help_signature=simple_help_signature,
ignore_in_history=ignore_in_history, ignore_in_history=ignore_in_history,
program=self.program,
) )
if hooks: if hooks:
@ -821,7 +816,11 @@ class Falyx:
logger.info("Command '%s' selected.", run_command.key) logger.info("Command '%s' selected.", run_command.key)
if is_preview: if is_preview:
return True, run_command, args, kwargs return True, run_command, args, kwargs
elif self.mode in {FalyxMode.RUN, FalyxMode.RUN_ALL, FalyxMode.PREVIEW}: elif self.options.get("mode") in {
FalyxMode.RUN,
FalyxMode.RUN_ALL,
FalyxMode.PREVIEW,
}:
return False, run_command, args, kwargs return False, run_command, args, kwargs
try: try:
args, kwargs = await run_command.parse_args(input_args, from_validate) args, kwargs = await run_command.parse_args(input_args, from_validate)
@ -1119,7 +1118,7 @@ class Falyx:
sys.exit(0) sys.exit(0)
if self.cli_args.command == "preview": if self.cli_args.command == "preview":
self.mode = FalyxMode.PREVIEW self.options.set("mode", FalyxMode.PREVIEW)
_, command, args, kwargs = await self.get_command(self.cli_args.name) _, command, args, kwargs = await self.get_command(self.cli_args.name)
if not command: if not command:
self.console.print( self.console.print(
@ -1133,7 +1132,7 @@ class Falyx:
sys.exit(0) sys.exit(0)
if self.cli_args.command == "run": if self.cli_args.command == "run":
self.mode = FalyxMode.RUN self.options.set("mode", FalyxMode.RUN)
is_preview, command, _, __ = await self.get_command(self.cli_args.name) is_preview, command, _, __ = await self.get_command(self.cli_args.name)
if is_preview: if is_preview:
if command is None: if command is None:
@ -1172,7 +1171,7 @@ class Falyx:
sys.exit(0) sys.exit(0)
if self.cli_args.command == "run-all": if self.cli_args.command == "run-all":
self.mode = FalyxMode.RUN_ALL self.options.set("mode", FalyxMode.RUN_ALL)
matching = [ matching = [
cmd cmd
for cmd in self.commands.values() for cmd in self.commands.values()

12
falyx/mode.py Normal file
View File

@ -0,0 +1,12 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""
Defines `FalyxMode`, an enum representing the different modes of operation for Falyx.
"""
from enum import Enum
class FalyxMode(Enum):
MENU = "menu"
RUN = "run"
PREVIEW = "preview"
RUN_ALL = "run-all"

View File

@ -41,6 +41,7 @@ class ArgumentAction(Enum):
EXTEND: Extend a list with multiple values. EXTEND: Extend a list with multiple values.
COUNT: Count the number of occurrences. COUNT: Count the number of occurrences.
HELP: Display help and exit. HELP: Display help and exit.
TLDR: Display brief examples and exit.
Aliases: Aliases:
- "true""store_true" - "true""store_true"
@ -60,6 +61,7 @@ class ArgumentAction(Enum):
EXTEND = "extend" EXTEND = "extend"
COUNT = "count" COUNT = "count"
HELP = "help" HELP = "help"
TLDR = "tldr"
@classmethod @classmethod
def choices(cls) -> list[ArgumentAction]: def choices(cls) -> list[ArgumentAction]:

View File

@ -53,10 +53,14 @@ from typing import Any, Iterable, Sequence
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
from rich.padding import Padding
from rich.panel import Panel
from falyx.action.base_action import BaseAction from falyx.action.base_action import BaseAction
from falyx.console import console from falyx.console import console
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
from falyx.parser.argument import Argument from falyx.parser.argument import Argument
from falyx.parser.argument_action import ArgumentAction from falyx.parser.argument_action import ArgumentAction
from falyx.parser.parser_types import false_none, true_none from falyx.parser.parser_types import false_none, true_none
@ -70,6 +74,12 @@ class ArgumentState:
consumed: bool = False consumed: bool = False
@dataclass(frozen=True)
class TLDRExample:
usage: str
description: str
class CommandArgumentParser: class CommandArgumentParser:
""" """
Custom argument parser for Falyx Commands. Custom argument parser for Falyx Commands.
@ -99,6 +109,9 @@ class CommandArgumentParser:
help_text: str = "", help_text: str = "",
help_epilog: str = "", help_epilog: str = "",
aliases: list[str] | None = None, aliases: list[str] | None = None,
tldr_examples: list[tuple[str, str]] | None = None,
program: str | None = None,
options_manager: OptionsManager | None = None,
) -> None: ) -> None:
"""Initialize the CommandArgumentParser.""" """Initialize the CommandArgumentParser."""
self.console: Console = console self.console: Console = console
@ -108,6 +121,7 @@ class CommandArgumentParser:
self.help_text: str = help_text self.help_text: str = help_text
self.help_epilog: str = help_epilog self.help_epilog: str = help_epilog
self.aliases: list[str] = aliases or [] self.aliases: list[str] = aliases or []
self.program: str | None = program
self._arguments: list[Argument] = [] self._arguments: list[Argument] = []
self._positional: dict[str, Argument] = {} self._positional: dict[str, Argument] = {}
self._keyword: dict[str, Argument] = {} self._keyword: dict[str, Argument] = {}
@ -117,6 +131,10 @@ class CommandArgumentParser:
self._add_help() self._add_help()
self._last_positional_states: dict[str, ArgumentState] = {} self._last_positional_states: dict[str, ArgumentState] = {}
self._last_keyword_states: dict[str, ArgumentState] = {} self._last_keyword_states: dict[str, ArgumentState] = {}
self._tldr_examples: list[TLDRExample] = []
if tldr_examples:
self.add_tldr_examples(tldr_examples)
self.options_manager: OptionsManager = options_manager or OptionsManager()
def _add_help(self): def _add_help(self):
"""Add help argument to the parser.""" """Add help argument to the parser."""
@ -128,6 +146,30 @@ class CommandArgumentParser:
dest="help", dest="help",
) )
def add_tldr_examples(self, examples: list[tuple[str, str]]) -> None:
"""
Add TLDR examples to the parser.
Args:
examples (list[tuple[str, str]]): List of (usage, description) tuples.
"""
if not any(
isinstance(example, tuple) and len(example) == 2 for example in examples
):
raise CommandArgumentError(
"TLDR examples must be a list of (usage, description) tuples"
)
for usage, description in examples:
self._tldr_examples.append(TLDRExample(usage=usage, description=description))
if "tldr" not in self._dest_set:
self.add_argument(
"--tldr",
action=ArgumentAction.TLDR,
help="Show quick usage examples and exit.",
dest="tldr",
)
def _is_positional(self, flags: tuple[str, ...]) -> bool: def _is_positional(self, flags: tuple[str, ...]) -> bool:
"""Check if the flags are positional.""" """Check if the flags are positional."""
positional = False positional = False
@ -180,6 +222,7 @@ class CommandArgumentParser:
ArgumentAction.STORE_FALSE, ArgumentAction.STORE_FALSE,
ArgumentAction.STORE_BOOL_OPTIONAL, ArgumentAction.STORE_BOOL_OPTIONAL,
ArgumentAction.HELP, ArgumentAction.HELP,
ArgumentAction.TLDR,
): ):
raise CommandArgumentError( raise CommandArgumentError(
f"Argument with action {action} cannot be required" f"Argument with action {action} cannot be required"
@ -212,6 +255,7 @@ class CommandArgumentParser:
ArgumentAction.STORE_TRUE, ArgumentAction.STORE_TRUE,
ArgumentAction.COUNT, ArgumentAction.COUNT,
ArgumentAction.HELP, ArgumentAction.HELP,
ArgumentAction.TLDR,
ArgumentAction.STORE_BOOL_OPTIONAL, ArgumentAction.STORE_BOOL_OPTIONAL,
): ):
if nargs is not None: if nargs is not None:
@ -320,6 +364,7 @@ class CommandArgumentParser:
ArgumentAction.STORE_BOOL_OPTIONAL, ArgumentAction.STORE_BOOL_OPTIONAL,
ArgumentAction.COUNT, ArgumentAction.COUNT,
ArgumentAction.HELP, ArgumentAction.HELP,
ArgumentAction.TLDR,
): ):
if positional: if positional:
raise CommandArgumentError( raise CommandArgumentError(
@ -764,6 +809,11 @@ class CommandArgumentParser:
self.render_help() self.render_help()
arg_states[spec.dest].consumed = True arg_states[spec.dest].consumed = True
raise HelpSignal() raise HelpSignal()
elif action == ArgumentAction.TLDR:
if not from_validate:
self.render_tldr()
arg_states[spec.dest].consumed = True
raise HelpSignal()
elif action == ArgumentAction.ACTION: elif action == ArgumentAction.ACTION:
assert isinstance( assert isinstance(
spec.resolver, BaseAction spec.resolver, BaseAction
@ -943,7 +993,11 @@ class CommandArgumentParser:
# Required validation # Required validation
for spec in self._arguments: for spec in self._arguments:
if spec.dest == "help": if (
spec.dest == "help"
or spec.dest == "tldr"
and spec.action == ArgumentAction.TLDR
):
continue continue
if spec.required and not result.get(spec.dest): if spec.required and not result.get(spec.dest):
help_text = f" help: {spec.help}" if spec.help else "" help_text = f" help: {spec.help}" if spec.help else ""
@ -1230,6 +1284,40 @@ class CommandArgumentParser:
if self.help_epilog: if self.help_epilog:
self.console.print("\n" + self.help_epilog, style="dim") self.console.print("\n" + self.help_epilog, style="dim")
def render_tldr(self) -> None:
"""
Print TLDR examples for this command using Rich output.
Displays brief usage examples with descriptions.
"""
if not self._tldr_examples:
self.console.print("[bold]No TLDR examples available.[/bold]")
return
is_cli_mode = self.options_manager.get("mode") in {
FalyxMode.RUN,
FalyxMode.PREVIEW,
FalyxMode.RUN_ALL,
}
program = self.program or "falyx"
command = self.aliases[0] if self.aliases else self.command_key
if is_cli_mode:
command = f"{program} run {command}"
command = f"[{self.command_style}]{command}[/{self.command_style}]"
usage = self.get_usage()
self.console.print(f"[bold]usage:[/] {usage}\n")
if self.help_text:
self.console.print(f"{self.help_text}\n")
self.console.print("[bold]examples:[/bold]")
for example in self._tldr_examples:
usage = f"{command} {example.usage.strip()}"
description = example.description.strip()
block = f"[bold]{usage}[/bold]\n{description}"
self.console.print(Padding(Panel(block, expand=False), (0, 2)))
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
if not isinstance(other, CommandArgumentParser): if not isinstance(other, CommandArgumentParser):
return False return False

View File

@ -14,8 +14,11 @@ from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import ( from prompt_toolkit.formatted_text import (
AnyFormattedText, AnyFormattedText,
FormattedText, FormattedText,
StyleAndTextTuples,
merge_formatted_text, merge_formatted_text,
) )
from rich.console import Console
from rich.text import Text
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.themes import OneColors from falyx.themes import OneColors
@ -56,3 +59,31 @@ async def confirm_async(
validator=yes_no_validator(), validator=yes_no_validator(),
) )
return answer.upper() == "Y" return answer.upper() == "Y"
def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples:
"""
Convert a Rich Text object to a list of (style, text) tuples
compatible with prompt_toolkit.
"""
if isinstance(text, list):
if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text):
return text
raise TypeError("Expected list of (style, text) tuples")
if isinstance(text, str):
text = Text.from_markup(text)
if not isinstance(text, Text):
raise TypeError("Expected str, rich.text.Text, or list of (style, text) tuples")
console = Console(color_system=None, file=None, width=999, legacy_windows=False)
segments = text.render(console)
prompt_fragments: StyleAndTextTuples = []
for segment in segments:
style = segment.style or ""
string = segment.text
if string:
prompt_fragments.append((str(style), string))
return prompt_fragments

View File

@ -21,6 +21,7 @@ from rich.markup import escape
from rich.table import Table from rich.table import Table
from falyx.console import console from falyx.console import console
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import MultiIndexValidator, MultiKeyValidator from falyx.validators import MultiIndexValidator, MultiKeyValidator
@ -293,7 +294,7 @@ async def prompt_for_index(
console.print(table, justify="center") console.print(table, justify="center")
selection = await prompt_session.prompt_async( selection = await prompt_session.prompt_async(
message=prompt_message, message=rich_text_to_prompt_text(prompt_message),
validator=MultiIndexValidator( validator=MultiIndexValidator(
min_index, min_index,
max_index, max_index,
@ -332,7 +333,7 @@ async def prompt_for_selection(
console.print(table, justify="center") console.print(table, justify="center")
selected = await prompt_session.prompt_async( selected = await prompt_session.prompt_async(
message=prompt_message, message=rich_text_to_prompt_text(prompt_message),
validator=MultiKeyValidator( validator=MultiKeyValidator(
keys, number_selections, separator, allow_duplicates, cancel_key keys, number_selections, separator, allow_duplicates, cancel_key
), ),

View File

@ -1 +1 @@
__version__ = "0.1.68" __version__ = "0.1.69"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.68" version = "0.1.69"
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

@ -7,7 +7,7 @@ from falyx.action import ConfirmAction
async def test_confirm_action_yes_no(): async def test_confirm_action_yes_no():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="yes_no", confirm_type="yes_no",
) )
@ -20,7 +20,7 @@ async def test_confirm_action_yes_no():
async def test_confirm_action_yes_cancel(): async def test_confirm_action_yes_cancel():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="yes_cancel", confirm_type="yes_cancel",
) )
@ -33,7 +33,7 @@ async def test_confirm_action_yes_cancel():
async def test_confirm_action_yes_no_cancel(): async def test_confirm_action_yes_no_cancel():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="yes_no_cancel", confirm_type="yes_no_cancel",
) )
@ -46,7 +46,7 @@ async def test_confirm_action_yes_no_cancel():
async def test_confirm_action_type_word(): async def test_confirm_action_type_word():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="type_word", confirm_type="type_word",
) )
@ -59,7 +59,7 @@ async def test_confirm_action_type_word():
async def test_confirm_action_type_word_cancel(): async def test_confirm_action_type_word_cancel():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="type_word_cancel", confirm_type="type_word_cancel",
) )
@ -72,7 +72,7 @@ async def test_confirm_action_type_word_cancel():
async def test_confirm_action_ok_cancel(): async def test_confirm_action_ok_cancel():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="ok_cancel", confirm_type="ok_cancel",
) )
@ -85,7 +85,7 @@ async def test_confirm_action_ok_cancel():
async def test_confirm_action_acknowledge(): async def test_confirm_action_acknowledge():
action = ConfirmAction( action = ConfirmAction(
name="test", name="test",
message="Are you sure?", prompt_message="Are you sure?",
never_prompt=True, never_prompt=True,
confirm_type="acknowledge", confirm_type="acknowledge",
) )

View File

@ -8,4 +8,4 @@ def test_argument_action():
assert action != "invalid_action" assert action != "invalid_action"
assert action.value == "append" assert action.value == "append"
assert str(action) == "append" assert str(action) == "append"
assert len(ArgumentAction.choices()) == 9 assert len(ArgumentAction.choices()) == 10