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,
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(
key="T",

View File

@ -84,7 +84,7 @@ async def main() -> None:
# --- Bottom bar info ---
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_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.hook_manager import HookType
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.themes import OneColors
from falyx.validators import word_validator, words_validator
@ -71,7 +75,7 @@ class ConfirmAction(BaseAction):
Attributes:
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.
Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL.
prompt_session (PromptSession | None): The session to use for input.
@ -84,7 +88,7 @@ class ConfirmAction(BaseAction):
def __init__(
self,
name: str,
message: str = "Confirm?",
prompt_message: str = "Confirm?",
confirm_type: ConfirmType | str = ConfirmType.YES_NO,
prompt_session: PromptSession | None = None,
never_prompt: bool = False,
@ -111,9 +115,11 @@ class ConfirmAction(BaseAction):
inject_into=inject_into,
never_prompt=never_prompt,
)
self.message = message
self.prompt_message = rich_text_to_prompt_text(prompt_message)
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.return_last_result = return_last_result
@ -122,7 +128,7 @@ class ConfirmAction(BaseAction):
match self.confirm_type:
case ConfirmType.YES_NO:
return await confirm_async(
self.message,
self.prompt_message,
prefix="",
suffix=" [Y/n] > ",
session=self.prompt_session,
@ -130,7 +136,7 @@ class ConfirmAction(BaseAction):
case ConfirmType.YES_NO_CANCEL:
error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort."
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(
["Y", "N", "C"], error_message=error_message
),
@ -140,13 +146,13 @@ class ConfirmAction(BaseAction):
return answer.upper() == "Y"
case ConfirmType.TYPE_WORD:
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),
)
return answer.upper().strip() != "N"
case ConfirmType.TYPE_WORD_CANCEL:
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),
)
if answer.upper().strip() == "N":
@ -154,7 +160,7 @@ class ConfirmAction(BaseAction):
return answer.upper().strip() == self.word.upper().strip()
case ConfirmType.YES_CANCEL:
answer = await confirm_async(
self.message,
self.prompt_message,
prefix="",
suffix=" [Y/n] > ",
session=self.prompt_session,
@ -165,7 +171,7 @@ class ConfirmAction(BaseAction):
case ConfirmType.OK_CANCEL:
error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort."
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),
)
if answer.upper() == "C":
@ -173,7 +179,7 @@ class ConfirmAction(BaseAction):
return answer.upper() == "O"
case ConfirmType.ACKNOWLEDGE:
answer = await self.prompt_session.prompt_async(
f"{self.message} [A]cknowledge > ",
f"{self.prompt_message} [A]cknowledge > ",
validator=word_validator("A"),
)
return answer.upper().strip() == "A"
@ -232,7 +238,7 @@ class ConfirmAction(BaseAction):
if not parent
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]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}")
if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL):
@ -242,6 +248,6 @@ class ConfirmAction(BaseAction):
def __str__(self) -> str:
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})"
)

View File

@ -49,8 +49,9 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
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.signals import BackSignal, QuitSignal
from falyx.signals import BackSignal, CancelSignal, QuitSignal
from falyx.themes import OneColors
from falyx.utils import chunks
@ -134,9 +135,11 @@ class MenuAction(BaseAction):
self.menu_options = menu_options
self.title = title
self.columns = columns
self.prompt_message = prompt_message
self.prompt_message = rich_text_to_prompt_text(prompt_message)
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.show_table = show_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.logger import logger
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
@ -96,9 +97,11 @@ class PromptMenuAction(BaseAction):
never_prompt=never_prompt,
)
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.prompt_session = prompt_session or PromptSession()
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.include_reserved = include_reserved
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.hook_manager import HookType
from falyx.logger import logger
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import (
SelectionOption,
prompt_for_selection,
@ -119,13 +120,15 @@ class SelectFileAction(BaseAction):
self.directory = Path(directory).resolve()
self.title = title
self.columns = columns
self.prompt_message = prompt_message
self.prompt_message = rich_text_to_prompt_text(prompt_message)
self.suffix_filter = suffix_filter
self.style = style
self.number_selections = number_selections
self.separator = separator
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.encoding = encoding

View File

@ -42,6 +42,7 @@ from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType
from falyx.logger import logger
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.selection import (
SelectionOption,
SelectionOptionMap,
@ -148,12 +149,14 @@ class SelectionAction(BaseAction):
self.return_type: SelectionReturnType = SelectionReturnType(return_type)
self.title = title
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.number_selections = number_selections
self.separator = separator
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
@property

View File

@ -22,7 +22,7 @@ Use Cases:
Example:
UserInputAction(
name="GetUsername",
prompt_text="Enter your username > ",
prompt_message="Enter your username > ",
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.execution_registry import ExecutionRegistry as er
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
@ -47,7 +49,7 @@ class UserInputAction(BaseAction):
Args:
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`.
default_text (str): Optional default value shown in the prompt.
validator (Validator | None): Prompt Toolkit validator for input constraints.
@ -59,7 +61,7 @@ class UserInputAction(BaseAction):
self,
name: str,
*,
prompt_text: str = "Input > ",
prompt_message: str = "Input > ",
default_text: str = "",
validator: Validator | None = None,
prompt_session: PromptSession | None = None,
@ -69,9 +71,11 @@ class UserInputAction(BaseAction):
name=name,
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.prompt_session = prompt_session or PromptSession()
self.prompt_session = prompt_session or PromptSession(
interrupt_exception=CancelSignal
)
self.default_text = default_text
def get_infer_target(self) -> tuple[None, None]:
@ -88,12 +92,12 @@ class UserInputAction(BaseAction):
try:
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:
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(
prompt_text,
prompt_message,
validator=self.validator,
default=kwargs.get("default_text", self.default_text),
)
@ -114,12 +118,12 @@ class UserInputAction(BaseAction):
label = f"[{OneColors.MAGENTA}]⌨ UserInputAction[/] '{self.name}'"
tree = parent.add(label) if parent else Tree(label)
prompt_text = (
self.prompt_text.replace("{last_result}", "<last_result>")
if "{last_result}" in self.prompt_text
else self.prompt_text
prompt_message = (
self.prompt_message.replace("{last_result}", "<last_result>")
if "{last_result}" in self.prompt_message
else self.prompt_message
)
tree.add(f"[dim]Prompt:[/] {prompt_text}")
tree.add(f"[dim]Prompt:[/] {prompt_message}")
if self.validator:
tree.add("[dim]Validator:[/] Yes")
if not parent:

View File

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

View File

@ -98,6 +98,7 @@ class Command(BaseModel):
such as help text or choices.
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.
program: (str | None): The parent program name.
Methods:
__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)
simple_help_signature: bool = False
ignore_in_history: bool = False
program: str | None = None
_context: ExecutionContext | None = PrivateAttr(default=None)
@ -240,6 +242,8 @@ class Command(BaseModel):
help_text=self.help_text,
help_epilog=self.help_epilog,
aliases=self.aliases,
program=self.program,
options_manager=self.options_manager,
)
for arg_def in self.get_argument_definitions():
self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)

View File

@ -26,12 +26,11 @@ import shlex
import sys
from argparse import ArgumentParser, Namespace, _SubParsersAction
from difflib import get_close_matches
from enum import Enum
from functools import cached_property
from typing import Any, Callable
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.patch_stdout import patch_stdout
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.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.mode import FalyxMode
from falyx.options_manager import OptionsManager
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.retry import RetryPolicy
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__
class FalyxMode(Enum):
MENU = "menu"
RUN = "run"
PREVIEW = "preview"
RUN_ALL = "run-all"
class CommandValidator(Validator):
"""Validator to check if the input is a valid command."""
@ -167,7 +161,7 @@ class Falyx:
epilog: str | None = None,
version: str = __version__,
version_style: str = OneColors.BLUE_b,
prompt: str | AnyFormattedText = "> ",
prompt: str | StyleAndTextTuples = "> ",
columns: int = 3,
bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
welcome_message: str | Markdown | dict[str, Any] = "",
@ -191,7 +185,7 @@ class Falyx:
self.epilog: str | None = epilog
self.version: str = version
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.commands: dict[str, Command] = CaseInsensitiveDict()
self.exit_command: Command = self._get_exit_command()
@ -216,7 +210,7 @@ class Falyx:
self._hide_menu_table: bool = hide_menu_table
self.validate_options(cli_args, options)
self._prompt_session: PromptSession | None = None
self.mode = FalyxMode.MENU
self.options.set("mode", FalyxMode.MENU)
def validate_options(
self,
@ -702,6 +696,7 @@ class Falyx:
arg_metadata=arg_metadata or {},
simple_help_signature=simple_help_signature,
ignore_in_history=ignore_in_history,
program=self.program,
)
if hooks:
@ -821,7 +816,11 @@ class Falyx:
logger.info("Command '%s' selected.", run_command.key)
if is_preview:
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
try:
args, kwargs = await run_command.parse_args(input_args, from_validate)
@ -1119,7 +1118,7 @@ class Falyx:
sys.exit(0)
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)
if not command:
self.console.print(
@ -1133,7 +1132,7 @@ class Falyx:
sys.exit(0)
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)
if is_preview:
if command is None:
@ -1172,7 +1171,7 @@ class Falyx:
sys.exit(0)
if self.cli_args.command == "run-all":
self.mode = FalyxMode.RUN_ALL
self.options.set("mode", FalyxMode.RUN_ALL)
matching = [
cmd
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.
COUNT: Count the number of occurrences.
HELP: Display help and exit.
TLDR: Display brief examples and exit.
Aliases:
- "true""store_true"
@ -60,6 +61,7 @@ class ArgumentAction(Enum):
EXTEND = "extend"
COUNT = "count"
HELP = "help"
TLDR = "tldr"
@classmethod
def choices(cls) -> list[ArgumentAction]:

View File

@ -53,10 +53,14 @@ from typing import Any, Iterable, Sequence
from rich.console import Console
from rich.markup import escape
from rich.padding import Padding
from rich.panel import Panel
from falyx.action.base_action import BaseAction
from falyx.console import console
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_action import ArgumentAction
from falyx.parser.parser_types import false_none, true_none
@ -70,6 +74,12 @@ class ArgumentState:
consumed: bool = False
@dataclass(frozen=True)
class TLDRExample:
usage: str
description: str
class CommandArgumentParser:
"""
Custom argument parser for Falyx Commands.
@ -99,6 +109,9 @@ class CommandArgumentParser:
help_text: str = "",
help_epilog: str = "",
aliases: list[str] | None = None,
tldr_examples: list[tuple[str, str]] | None = None,
program: str | None = None,
options_manager: OptionsManager | None = None,
) -> None:
"""Initialize the CommandArgumentParser."""
self.console: Console = console
@ -108,6 +121,7 @@ class CommandArgumentParser:
self.help_text: str = help_text
self.help_epilog: str = help_epilog
self.aliases: list[str] = aliases or []
self.program: str | None = program
self._arguments: list[Argument] = []
self._positional: dict[str, Argument] = {}
self._keyword: dict[str, Argument] = {}
@ -117,6 +131,10 @@ class CommandArgumentParser:
self._add_help()
self._last_positional_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):
"""Add help argument to the parser."""
@ -128,6 +146,30 @@ class CommandArgumentParser:
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:
"""Check if the flags are positional."""
positional = False
@ -180,6 +222,7 @@ class CommandArgumentParser:
ArgumentAction.STORE_FALSE,
ArgumentAction.STORE_BOOL_OPTIONAL,
ArgumentAction.HELP,
ArgumentAction.TLDR,
):
raise CommandArgumentError(
f"Argument with action {action} cannot be required"
@ -212,6 +255,7 @@ class CommandArgumentParser:
ArgumentAction.STORE_TRUE,
ArgumentAction.COUNT,
ArgumentAction.HELP,
ArgumentAction.TLDR,
ArgumentAction.STORE_BOOL_OPTIONAL,
):
if nargs is not None:
@ -320,6 +364,7 @@ class CommandArgumentParser:
ArgumentAction.STORE_BOOL_OPTIONAL,
ArgumentAction.COUNT,
ArgumentAction.HELP,
ArgumentAction.TLDR,
):
if positional:
raise CommandArgumentError(
@ -764,6 +809,11 @@ class CommandArgumentParser:
self.render_help()
arg_states[spec.dest].consumed = True
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:
assert isinstance(
spec.resolver, BaseAction
@ -943,7 +993,11 @@ class CommandArgumentParser:
# Required validation
for spec in self._arguments:
if spec.dest == "help":
if (
spec.dest == "help"
or spec.dest == "tldr"
and spec.action == ArgumentAction.TLDR
):
continue
if spec.required and not result.get(spec.dest):
help_text = f" help: {spec.help}" if spec.help else ""
@ -1230,6 +1284,40 @@ class CommandArgumentParser:
if self.help_epilog:
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:
if not isinstance(other, CommandArgumentParser):
return False

View File

@ -14,8 +14,11 @@ from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import (
AnyFormattedText,
FormattedText,
StyleAndTextTuples,
merge_formatted_text,
)
from rich.console import Console
from rich.text import Text
from falyx.options_manager import OptionsManager
from falyx.themes import OneColors
@ -56,3 +59,31 @@ async def confirm_async(
validator=yes_no_validator(),
)
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 falyx.console import console
from falyx.prompt_utils import rich_text_to_prompt_text
from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import MultiIndexValidator, MultiKeyValidator
@ -293,7 +294,7 @@ async def prompt_for_index(
console.print(table, justify="center")
selection = await prompt_session.prompt_async(
message=prompt_message,
message=rich_text_to_prompt_text(prompt_message),
validator=MultiIndexValidator(
min_index,
max_index,
@ -332,7 +333,7 @@ async def prompt_for_selection(
console.print(table, justify="center")
selected = await prompt_session.prompt_async(
message=prompt_message,
message=rich_text_to_prompt_text(prompt_message),
validator=MultiKeyValidator(
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]
name = "falyx"
version = "0.1.68"
version = "0.1.69"
description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT"

View File

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

View File

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