display.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975
  1. from __future__ import annotations
  2. import logging
  3. from pathlib import Path
  4. from typing import TYPE_CHECKING
  5. from rich.console import Console
  6. from rich.table import Table
  7. from rich.tree import Tree
  8. if TYPE_CHECKING:
  9. from .exceptions import TemplateRenderError
  10. from .template import Template
  11. logger = logging.getLogger(__name__)
  12. console = Console()
  13. console_err = Console(stderr=True)
  14. class IconManager:
  15. """Centralized icon management system for consistent CLI display.
  16. This class provides standardized icons for file types, status indicators,
  17. and UI elements. Icons use Nerd Font glyphs for consistent display.
  18. Categories:
  19. - File types: .yaml, .j2, .json, .md, etc.
  20. - Status: success, warning, error, info, skipped
  21. - UI elements: folders, config, locks, etc.
  22. """
  23. # File Type Icons
  24. FILE_FOLDER = "\uf07b" #
  25. FILE_DEFAULT = "\uf15b" #
  26. FILE_YAML = "\uf15c" #
  27. FILE_JSON = "\ue60b" #
  28. FILE_MARKDOWN = "\uf48a" #
  29. FILE_JINJA2 = "\ue235" #
  30. FILE_DOCKER = "\uf308" #
  31. FILE_COMPOSE = "\uf308" #
  32. FILE_SHELL = "\uf489" #
  33. FILE_PYTHON = "\ue73c" #
  34. FILE_TEXT = "\uf15c" #
  35. # Status Indicators
  36. STATUS_SUCCESS = "\uf00c" # (check)
  37. STATUS_ERROR = "\uf00d" # (times/x)
  38. STATUS_WARNING = "\uf071" # (exclamation-triangle)
  39. STATUS_INFO = "\uf05a" # (info-circle)
  40. STATUS_SKIPPED = "\uf05e" # (ban/circle-slash)
  41. # UI Elements
  42. UI_CONFIG = "\ue5fc" #
  43. UI_LOCK = "\uf084" #
  44. UI_SETTINGS = "\uf013" #
  45. UI_ARROW_RIGHT = "\uf061" # (arrow-right)
  46. UI_BULLET = "\uf111" # (circle)
  47. UI_LIBRARY_GIT = "\uf418" # (git icon)
  48. UI_LIBRARY_STATIC = "\uf07c" # (folder icon)
  49. @classmethod
  50. def get_file_icon(cls, file_path: str | Path) -> str:
  51. """Get the appropriate icon for a file based on its extension or name.
  52. Args:
  53. file_path: Path to the file (can be string or Path object)
  54. Returns:
  55. Unicode icon character for the file type
  56. Examples:
  57. >>> IconManager.get_file_icon("config.yaml")
  58. '\uf15c'
  59. >>> IconManager.get_file_icon("template.j2")
  60. '\ue235'
  61. """
  62. if isinstance(file_path, str):
  63. file_path = Path(file_path)
  64. file_name = file_path.name.lower()
  65. suffix = file_path.suffix.lower()
  66. # Check for Docker Compose files
  67. compose_names = {
  68. "docker-compose.yml",
  69. "docker-compose.yaml",
  70. "compose.yml",
  71. "compose.yaml",
  72. }
  73. if file_name in compose_names or file_name.startswith("docker-compose"):
  74. return cls.FILE_DOCKER
  75. # Check by extension
  76. extension_map = {
  77. ".yaml": cls.FILE_YAML,
  78. ".yml": cls.FILE_YAML,
  79. ".json": cls.FILE_JSON,
  80. ".md": cls.FILE_MARKDOWN,
  81. ".j2": cls.FILE_JINJA2,
  82. ".sh": cls.FILE_SHELL,
  83. ".py": cls.FILE_PYTHON,
  84. ".txt": cls.FILE_TEXT,
  85. }
  86. return extension_map.get(suffix, cls.FILE_DEFAULT)
  87. @classmethod
  88. def get_status_icon(cls, status: str) -> str:
  89. """Get the appropriate icon for a status indicator.
  90. Args:
  91. status: Status type (success, error, warning, info, skipped)
  92. Returns:
  93. Unicode icon character for the status
  94. Examples:
  95. >>> IconManager.get_status_icon("success")
  96. '✓'
  97. >>> IconManager.get_status_icon("warning")
  98. '⚠'
  99. """
  100. status_map = {
  101. "success": cls.STATUS_SUCCESS,
  102. "error": cls.STATUS_ERROR,
  103. "warning": cls.STATUS_WARNING,
  104. "info": cls.STATUS_INFO,
  105. "skipped": cls.STATUS_SKIPPED,
  106. }
  107. return status_map.get(status.lower(), cls.STATUS_INFO)
  108. @classmethod
  109. def folder(cls) -> str:
  110. """Get the folder icon."""
  111. return cls.FILE_FOLDER
  112. @classmethod
  113. def config(cls) -> str:
  114. """Get the config icon."""
  115. return cls.UI_CONFIG
  116. @classmethod
  117. def lock(cls) -> str:
  118. """Get the lock icon (for sensitive variables)."""
  119. return cls.UI_LOCK
  120. @classmethod
  121. def arrow_right(cls) -> str:
  122. """Get the right arrow icon (for showing transitions/changes)."""
  123. return cls.UI_ARROW_RIGHT
  124. class DisplayManager:
  125. """Handles all rich rendering for the CLI.
  126. This class is responsible for ALL display output in the CLI, including:
  127. - Status messages (success, error, warning, info)
  128. - Tables (templates, summaries, results)
  129. - Trees (file structures, configurations)
  130. - Confirmation dialogs and prompts
  131. - Headers and sections
  132. Design Principles:
  133. - All display logic should go through DisplayManager methods
  134. - IconManager is ONLY used internally by DisplayManager
  135. - External code should never directly call IconManager or console.print
  136. - Consistent formatting across all display types
  137. """
  138. def __init__(self, quiet: bool = False):
  139. """Initialize DisplayManager.
  140. Args:
  141. quiet: If True, suppress all non-error output
  142. """
  143. self.quiet = quiet
  144. def display_templates_table(
  145. self, templates: list, module_name: str, title: str
  146. ) -> None:
  147. """Display a table of templates with library type indicators.
  148. Args:
  149. templates: List of Template objects
  150. module_name: Name of the module
  151. title: Title for the table
  152. """
  153. if not templates:
  154. logger.info(f"No templates found for module '{module_name}'")
  155. return
  156. logger.info(f"Listing {len(templates)} templates for module '{module_name}'")
  157. table = Table(title=title)
  158. table.add_column("ID", style="bold", no_wrap=True)
  159. table.add_column("Name")
  160. table.add_column("Tags")
  161. table.add_column("Version", no_wrap=True)
  162. table.add_column("Schema", no_wrap=True)
  163. table.add_column("Library", no_wrap=True)
  164. for template in templates:
  165. name = template.metadata.name or "Unnamed Template"
  166. tags_list = template.metadata.tags or []
  167. tags = ", ".join(tags_list) if tags_list else "-"
  168. version = (
  169. str(template.metadata.version) if template.metadata.version else ""
  170. )
  171. schema = (
  172. template.schema_version
  173. if hasattr(template, "schema_version")
  174. else "1.0"
  175. )
  176. # Show library with type indicator and color
  177. library_name = template.metadata.library or ""
  178. library_type = template.metadata.library_type or "git"
  179. if library_type == "static":
  180. # Static libraries: yellow/amber color with folder icon
  181. library_display = (
  182. f"[yellow]{IconManager.UI_LIBRARY_STATIC} {library_name}[/yellow]"
  183. )
  184. else:
  185. # Git libraries: blue color with git icon
  186. library_display = (
  187. f"[blue]{IconManager.UI_LIBRARY_GIT} {library_name}[/blue]"
  188. )
  189. # Display qualified ID if present (e.g., "alloy.default")
  190. display_id = template.id
  191. table.add_row(display_id, name, tags, version, schema, library_display)
  192. console.print(table)
  193. def display_template_details(self, template: Template, template_id: str) -> None:
  194. """Display template information panel and variables table.
  195. Args:
  196. template: Template instance to display
  197. template_id: ID of the template
  198. """
  199. self._display_template_header(template, template_id)
  200. self._display_file_tree(template)
  201. self._display_variables_table(template)
  202. def display_section_header(self, title: str, description: str | None) -> None:
  203. """Display a section header."""
  204. if description:
  205. console.print(
  206. f"\n[bold cyan]{title}[/bold cyan] [dim]- {description}[/dim]"
  207. )
  208. else:
  209. console.print(f"\n[bold cyan]{title}[/bold cyan]")
  210. console.print("─" * 40, style="dim")
  211. def display_validation_error(self, message: str) -> None:
  212. """Display a validation error message."""
  213. self.display_message("error", message)
  214. def display_message(
  215. self, level: str, message: str, context: str | None = None
  216. ) -> None:
  217. """Display a message with consistent formatting.
  218. Args:
  219. level: Message level (error, warning, success, info)
  220. message: The message to display
  221. context: Optional context information
  222. """
  223. # Errors and warnings always go to stderr, even in quiet mode
  224. # Success and info respect quiet mode and go to stdout
  225. if level in ("error", "warning"):
  226. output_console = console_err
  227. should_print = True
  228. else:
  229. output_console = console
  230. should_print = not self.quiet
  231. if not should_print:
  232. return
  233. icon = IconManager.get_status_icon(level)
  234. colors = {
  235. "error": "red",
  236. "warning": "yellow",
  237. "success": "green",
  238. "info": "blue",
  239. }
  240. color = colors.get(level, "white")
  241. # Format message based on context
  242. if context:
  243. text = (
  244. f"{level.capitalize()} in {context}: {message}"
  245. if level == "error" or level == "warning"
  246. else f"{context}: {message}"
  247. )
  248. else:
  249. text = (
  250. f"{level.capitalize()}: {message}"
  251. if level == "error" or level == "warning"
  252. else message
  253. )
  254. output_console.print(f"[{color}]{icon} {text}[/{color}]")
  255. # Log appropriately
  256. log_message = f"{context}: {message}" if context else message
  257. log_methods = {
  258. "error": logger.error,
  259. "warning": logger.warning,
  260. "success": logger.info,
  261. "info": logger.info,
  262. }
  263. log_methods.get(level, logger.info)(log_message)
  264. def display_error(self, message: str, context: str | None = None) -> None:
  265. """Display an error message."""
  266. self.display_message("error", message, context)
  267. def display_warning(self, message: str, context: str | None = None) -> None:
  268. """Display a warning message."""
  269. self.display_message("warning", message, context)
  270. def display_success(self, message: str, context: str | None = None) -> None:
  271. """Display a success message."""
  272. self.display_message("success", message, context)
  273. def display_info(self, message: str, context: str | None = None) -> None:
  274. """Display an informational message."""
  275. self.display_message("info", message, context)
  276. def display_version_incompatibility(
  277. self, template_id: str, required_version: str, current_version: str
  278. ) -> None:
  279. """Display a version incompatibility error with upgrade instructions.
  280. Args:
  281. template_id: ID of the incompatible template
  282. required_version: Minimum CLI version required by template
  283. current_version: Current CLI version
  284. """
  285. console_err.print()
  286. console_err.print(
  287. f"[bold red]{IconManager.STATUS_ERROR} Version Incompatibility[/bold red]"
  288. )
  289. console_err.print()
  290. console_err.print(
  291. f"Template '[cyan]{template_id}[/cyan]' requires CLI version [green]{required_version}[/green] or higher."
  292. )
  293. console_err.print(f"Current CLI version: [yellow]{current_version}[/yellow]")
  294. console_err.print()
  295. console_err.print("[bold]Upgrade Instructions:[/bold]")
  296. console_err.print(
  297. f" {IconManager.UI_ARROW_RIGHT} Run: [cyan]pip install --upgrade boilerplates[/cyan]"
  298. )
  299. console_err.print(
  300. f" {IconManager.UI_ARROW_RIGHT} Or install specific version: [cyan]pip install boilerplates=={required_version}[/cyan]"
  301. )
  302. console_err.print()
  303. logger.error(
  304. f"Template '{template_id}' requires CLI version {required_version}, "
  305. f"current version is {current_version}"
  306. )
  307. def _display_template_header(self, template: Template, template_id: str) -> None:
  308. """Display the header for a template with library information."""
  309. template_name = template.metadata.name or "Unnamed Template"
  310. version = (
  311. str(template.metadata.version)
  312. if template.metadata.version
  313. else "Not specified"
  314. )
  315. schema = (
  316. template.schema_version if hasattr(template, "schema_version") else "1.0"
  317. )
  318. description = template.metadata.description or "No description available"
  319. # Get library information
  320. library_name = template.metadata.library or ""
  321. library_type = template.metadata.library_type or "git"
  322. # Format library display with icon and color
  323. if library_type == "static":
  324. library_display = (
  325. f"[yellow]{IconManager.UI_LIBRARY_STATIC} {library_name}[/yellow]"
  326. )
  327. else:
  328. library_display = (
  329. f"[blue]{IconManager.UI_LIBRARY_GIT} {library_name}[/blue]"
  330. )
  331. console.print(
  332. f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan] - [magenta]schema {schema}[/magenta]) {library_display}[/bold blue]"
  333. )
  334. console.print(description)
  335. def _build_file_tree(
  336. self, root_label: str, files: list, get_file_info: callable
  337. ) -> Tree:
  338. """Build a file tree structure.
  339. Args:
  340. root_label: Label for root node
  341. files: List of files to display
  342. get_file_info: Function that takes a file and returns (path, display_name, color, extra_text)
  343. Returns:
  344. Tree object ready for display
  345. """
  346. file_tree = Tree(root_label)
  347. tree_nodes = {Path("."): file_tree}
  348. for file_item in sorted(files, key=lambda f: get_file_info(f)[0]):
  349. path, display_name, color, extra_text = get_file_info(file_item)
  350. parts = path.parts
  351. current_path = Path(".")
  352. current_node = file_tree
  353. # Build directory structure
  354. for part in parts[:-1]:
  355. current_path = current_path / part
  356. if current_path not in tree_nodes:
  357. new_node = current_node.add(
  358. f"{IconManager.folder()} [white]{part}[/white]"
  359. )
  360. tree_nodes[current_path] = new_node
  361. current_node = tree_nodes[current_path]
  362. # Add file
  363. icon = IconManager.get_file_icon(display_name)
  364. file_label = f"{icon} [{color}]{display_name}[/{color}]"
  365. if extra_text:
  366. file_label += f" {extra_text}"
  367. current_node.add(file_label)
  368. return file_tree
  369. def _display_file_tree(self, template: Template) -> None:
  370. """Display the file structure of a template."""
  371. console.print()
  372. console.print("[bold blue]Template File Structure:[/bold blue]")
  373. def get_template_file_info(template_file):
  374. display_name = (
  375. template_file.output_path.name
  376. if hasattr(template_file, "output_path")
  377. else template_file.relative_path.name
  378. )
  379. return (template_file.relative_path, display_name, "white", None)
  380. file_tree = self._build_file_tree(
  381. f"{IconManager.folder()} [white]{template.id}[/white]",
  382. template.template_files,
  383. get_template_file_info,
  384. )
  385. if file_tree.children:
  386. console.print(file_tree)
  387. def _display_variables_table(self, template: Template) -> None:
  388. """Display a table of variables for a template.
  389. All variables and sections are always shown. Disabled sections/variables
  390. are displayed with dimmed styling.
  391. Args:
  392. template: Template instance
  393. """
  394. if not (template.variables and template.variables.has_sections()):
  395. return
  396. console.print()
  397. console.print("[bold blue]Template Variables:[/bold blue]")
  398. variables_table = Table(show_header=True, header_style="bold blue")
  399. variables_table.add_column("Variable", style="white", no_wrap=True)
  400. variables_table.add_column("Type", style="magenta")
  401. variables_table.add_column("Default", style="green")
  402. variables_table.add_column("Description", style="white")
  403. first_section = True
  404. for section in template.variables.get_sections().values():
  405. if not section.variables:
  406. continue
  407. if not first_section:
  408. variables_table.add_row("", "", "", "", style="bright_black")
  409. first_section = False
  410. # Check if section is enabled AND dependencies are satisfied
  411. is_enabled = section.is_enabled()
  412. dependencies_satisfied = template.variables.is_section_satisfied(
  413. section.key
  414. )
  415. is_dimmed = not (is_enabled and dependencies_satisfied)
  416. # Only show (disabled) if section has no dependencies (dependencies make it obvious)
  417. # Empty list means no dependencies (same as None)
  418. has_dependencies = section.needs and len(section.needs) > 0
  419. disabled_text = (
  420. " (disabled)" if (is_dimmed and not has_dependencies) else ""
  421. )
  422. # For disabled sections, make entire heading bold and dim (don't include colored markup inside)
  423. if is_dimmed:
  424. # Build text without internal markup, then wrap entire thing in bold bright_black (dimmed appearance)
  425. required_part = " (required)" if section.required else ""
  426. header_text = f"[bold bright_black]{section.title}{required_part}{disabled_text}[/bold bright_black]"
  427. else:
  428. # For enabled sections, include the colored markup
  429. required_text = (
  430. " [yellow](required)[/yellow]" if section.required else ""
  431. )
  432. header_text = (
  433. f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
  434. )
  435. variables_table.add_row(header_text, "", "", "")
  436. for var_name, variable in section.variables.items():
  437. # Skip toggle variable in required sections (always enabled, no need to show)
  438. if section.required and section.toggle and var_name == section.toggle:
  439. continue
  440. # Check if variable's needs are satisfied
  441. var_satisfied = template.variables.is_variable_satisfied(var_name)
  442. # Dim the variable if section is dimmed OR variable needs are not satisfied
  443. row_style = "bright_black" if (is_dimmed or not var_satisfied) else None
  444. # Build default value display
  445. # Special case: disabled bool variables show as "original → False"
  446. if (is_dimmed or not var_satisfied) and variable.type == "bool":
  447. # Show that disabled bool variables are forced to False
  448. if (
  449. hasattr(variable, "_original_disabled")
  450. and variable._original_disabled is not False
  451. ):
  452. orig_val = str(variable._original_disabled)
  453. default_val = f"{orig_val} {IconManager.arrow_right()} False"
  454. else:
  455. default_val = "False"
  456. # If origin is 'config' and original value differs from current, show: original → config_value
  457. # BUT only for enabled variables (don't show arrow for disabled ones)
  458. elif (
  459. not (is_dimmed or not var_satisfied)
  460. and variable.origin == "config"
  461. and hasattr(variable, "_original_stored")
  462. and variable.original_value != variable.value
  463. ):
  464. # Format original value (use same display logic, but shorter)
  465. if variable.sensitive:
  466. orig_display = "********"
  467. elif (
  468. variable.original_value is None or variable.original_value == ""
  469. ):
  470. orig_display = "[dim](none)[/dim]"
  471. else:
  472. orig_val_str = str(variable.original_value)
  473. orig_display = (
  474. orig_val_str[:15] + "..."
  475. if len(orig_val_str) > 15
  476. else orig_val_str
  477. )
  478. # Get current (config) value display (without showing "(none)" since we have the arrow)
  479. config_display = variable.get_display_value(
  480. mask_sensitive=True, max_length=15, show_none=False
  481. )
  482. if (
  483. not config_display
  484. ): # If still empty after show_none=False, show actual value
  485. config_display = (
  486. str(variable.value) if variable.value else "(empty)"
  487. )
  488. # Highlight the arrow and config value in bold yellow to show it's a custom override
  489. default_val = f"{orig_display} [bold yellow]{IconManager.arrow_right()} {config_display}[/bold yellow]"
  490. else:
  491. # Use variable's native get_display_value() method (shows "(none)" for empty)
  492. default_val = variable.get_display_value(
  493. mask_sensitive=True, max_length=30, show_none=True
  494. )
  495. # Add lock icon for sensitive variables
  496. sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
  497. # Add required indicator for required variables
  498. required_indicator = (
  499. " [yellow](required)[/yellow]" if variable.required else ""
  500. )
  501. var_display = f" {var_name}{sensitive_icon}{required_indicator}"
  502. variables_table.add_row(
  503. var_display,
  504. variable.type or "str",
  505. default_val,
  506. variable.description or "",
  507. style=row_style,
  508. )
  509. console.print(variables_table)
  510. def display_file_generation_confirmation(
  511. self,
  512. output_dir: Path,
  513. files: dict[str, str],
  514. existing_files: list[Path] | None = None,
  515. ) -> None:
  516. """Display files to be generated with confirmation prompt."""
  517. console.print()
  518. console.print("[bold]Files to be generated:[/bold]")
  519. def get_file_generation_info(file_path_str):
  520. file_path = Path(file_path_str)
  521. file_name = file_path.parts[-1] if file_path.parts else file_path.name
  522. full_path = output_dir / file_path
  523. if existing_files and full_path in existing_files:
  524. return (file_path, file_name, "yellow", "[red](will overwrite)[/red]")
  525. else:
  526. return (file_path, file_name, "green", None)
  527. file_tree = self._build_file_tree(
  528. f"{IconManager.folder()} [cyan]{output_dir.resolve()}[/cyan]",
  529. files.keys(),
  530. get_file_generation_info,
  531. )
  532. console.print(file_tree)
  533. console.print()
  534. def display_config_tree(
  535. self, spec: dict, module_name: str, show_all: bool = False
  536. ) -> None:
  537. """Display configuration spec as a tree view.
  538. Args:
  539. spec: The configuration spec dictionary
  540. module_name: Name of the module
  541. show_all: If True, show all details including descriptions
  542. """
  543. if not spec:
  544. console.print(
  545. f"[yellow]No configuration found for module '{module_name}'[/yellow]"
  546. )
  547. return
  548. # Create root tree node
  549. tree = Tree(
  550. f"[bold blue]{IconManager.config()} {str.capitalize(module_name)} Configuration[/bold blue]"
  551. )
  552. for section_name, section_data in spec.items():
  553. if not isinstance(section_data, dict):
  554. continue
  555. # Determine if this is a section with variables
  556. # Guard against None from empty YAML sections
  557. section_vars = section_data.get("vars") or {}
  558. section_desc = section_data.get("description", "")
  559. section_required = section_data.get("required", False)
  560. section_toggle = section_data.get("toggle", None)
  561. section_needs = section_data.get("needs", None)
  562. # Build section label
  563. section_label = f"[cyan]{section_name}[/cyan]"
  564. if section_required:
  565. section_label += " [yellow](required)[/yellow]"
  566. if section_toggle:
  567. section_label += f" [dim](toggle: {section_toggle})[/dim]"
  568. if section_needs:
  569. needs_str = (
  570. ", ".join(section_needs)
  571. if isinstance(section_needs, list)
  572. else section_needs
  573. )
  574. section_label += f" [dim](needs: {needs_str})[/dim]"
  575. if show_all and section_desc:
  576. section_label += f"\n [dim]{section_desc}[/dim]"
  577. section_node = tree.add(section_label)
  578. # Add variables
  579. if section_vars:
  580. for var_name, var_data in section_vars.items():
  581. if isinstance(var_data, dict):
  582. var_type = var_data.get("type", "string")
  583. var_default = var_data.get("default", "")
  584. var_desc = var_data.get("description", "")
  585. var_sensitive = var_data.get("sensitive", False)
  586. # Build variable label
  587. var_label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
  588. if var_default is not None and var_default != "":
  589. display_val = (
  590. "********" if var_sensitive else str(var_default)
  591. )
  592. if not var_sensitive and len(display_val) > 30:
  593. display_val = display_val[:27] + "..."
  594. var_label += f" = [yellow]{display_val}[/yellow]"
  595. if show_all and var_desc:
  596. var_label += f"\n [dim]{var_desc}[/dim]"
  597. section_node.add(var_label)
  598. else:
  599. # Simple key-value pair
  600. section_node.add(
  601. f"[green]{var_name}[/green] = [yellow]{var_data}[/yellow]"
  602. )
  603. console.print(tree)
  604. def display_next_steps(self, next_steps: str, variable_values: dict) -> None:
  605. """Display next steps after template generation, rendering them as a Jinja2 template.
  606. Args:
  607. next_steps: The next_steps string from template metadata (may contain Jinja2 syntax)
  608. variable_values: Dictionary of variable values to use for rendering
  609. """
  610. if not next_steps:
  611. return
  612. console.print("\n[bold cyan]Next Steps:[/bold cyan]")
  613. try:
  614. from jinja2 import Template as Jinja2Template
  615. next_steps_template = Jinja2Template(next_steps)
  616. rendered_next_steps = next_steps_template.render(variable_values)
  617. console.print(rendered_next_steps)
  618. except Exception as e:
  619. logger.warning(f"Failed to render next_steps as template: {e}")
  620. # Fallback to plain text if rendering fails
  621. console.print(next_steps)
  622. def display_status_table(
  623. self,
  624. title: str,
  625. rows: list[tuple[str, str, bool]],
  626. columns: tuple[str, str] = ("Item", "Status"),
  627. ) -> None:
  628. """Display a status table with success/error indicators.
  629. Args:
  630. title: Table title
  631. rows: List of tuples (name, message, success_bool)
  632. columns: Column headers (name_header, status_header)
  633. """
  634. table = Table(title=title, show_header=True)
  635. table.add_column(columns[0], style="cyan", no_wrap=True)
  636. table.add_column(columns[1])
  637. for name, message, success in rows:
  638. status_style = "green" if success else "red"
  639. status_icon = IconManager.get_status_icon("success" if success else "error")
  640. table.add_row(
  641. name, f"[{status_style}]{status_icon} {message}[/{status_style}]"
  642. )
  643. console.print(table)
  644. def display_summary_table(self, title: str, items: dict[str, str]) -> None:
  645. """Display a simple two-column summary table.
  646. Args:
  647. title: Table title
  648. items: Dictionary of key-value pairs to display
  649. """
  650. table = Table(title=title, show_header=False, box=None, padding=(0, 2))
  651. table.add_column(style="bold")
  652. table.add_column()
  653. for key, value in items.items():
  654. table.add_row(key, value)
  655. console.print(table)
  656. def display_file_operation_table(self, files: list[tuple[str, int, str]]) -> None:
  657. """Display a table of file operations with sizes and statuses.
  658. Args:
  659. files: List of tuples (file_path, size_bytes, status)
  660. """
  661. table = Table(
  662. show_header=True, header_style="bold cyan", box=None, padding=(0, 1)
  663. )
  664. table.add_column("File", style="white", no_wrap=False)
  665. table.add_column("Size", justify="right", style="dim")
  666. table.add_column("Status", style="yellow")
  667. for file_path, size_bytes, status in files:
  668. # Format size
  669. if size_bytes < 1024:
  670. size_str = f"{size_bytes}B"
  671. elif size_bytes < 1024 * 1024:
  672. size_str = f"{size_bytes / 1024:.1f}KB"
  673. else:
  674. size_str = f"{size_bytes / (1024 * 1024):.1f}MB"
  675. table.add_row(str(file_path), size_str, status)
  676. console.print(table)
  677. def display_heading(
  678. self, text: str, icon_type: str | None = None, style: str = "bold"
  679. ) -> None:
  680. """Display a heading with optional icon.
  681. Args:
  682. text: Heading text
  683. icon_type: Type of icon to display (e.g., 'folder', 'file', 'config')
  684. style: Rich style to apply
  685. """
  686. if icon_type:
  687. icon = self._get_icon_by_type(icon_type)
  688. console.print(f"[{style}]{icon} {text}[/{style}]")
  689. else:
  690. console.print(f"[{style}]{text}[/{style}]")
  691. def display_warning_with_confirmation(
  692. self, message: str, details: list[str] | None = None, default: bool = False
  693. ) -> bool:
  694. """Display a warning message with optional details and get confirmation.
  695. Args:
  696. message: Warning message to display
  697. details: Optional list of detail lines to show
  698. default: Default value for confirmation
  699. Returns:
  700. True if user confirms, False otherwise
  701. """
  702. icon = IconManager.get_status_icon("warning")
  703. console.print(f"\n[yellow]{icon} {message}[/yellow]")
  704. if details:
  705. for detail in details:
  706. console.print(f"[yellow] {detail}[/yellow]")
  707. from rich.prompt import Confirm
  708. return Confirm.ask("Continue?", default=default)
  709. def display_skipped(self, message: str, reason: str | None = None) -> None:
  710. """Display a skipped/disabled message.
  711. Args:
  712. message: The main message to display
  713. reason: Optional reason why it was skipped
  714. """
  715. icon = IconManager.get_status_icon("skipped")
  716. if reason:
  717. console.print(f"\n[dim]{icon} {message} (skipped - {reason})[/dim]")
  718. else:
  719. console.print(f"\n[dim]{icon} {message} (skipped)[/dim]")
  720. def get_lock_icon(self) -> str:
  721. """Get the lock icon for sensitive variables.
  722. Returns:
  723. Lock icon unicode character
  724. """
  725. return IconManager.lock()
  726. def _get_icon_by_type(self, icon_type: str) -> str:
  727. """Get icon by semantic type name.
  728. Args:
  729. icon_type: Type of icon (e.g., 'folder', 'file', 'config', 'lock')
  730. Returns:
  731. Icon unicode character
  732. """
  733. icon_map = {
  734. "folder": IconManager.folder(),
  735. "file": IconManager.FILE_DEFAULT,
  736. "config": IconManager.config(),
  737. "lock": IconManager.lock(),
  738. "arrow": IconManager.arrow_right(),
  739. }
  740. return icon_map.get(icon_type, "")
  741. def display_template_render_error(
  742. self, error: "TemplateRenderError", context: str | None = None
  743. ) -> None:
  744. """Display a detailed template rendering error with context and suggestions.
  745. Args:
  746. error: TemplateRenderError exception with detailed error information
  747. context: Optional context information (e.g., template ID)
  748. """
  749. from rich.panel import Panel
  750. from rich.syntax import Syntax
  751. # Always display errors to stderr
  752. # Display main error header
  753. icon = IconManager.get_status_icon("error")
  754. if context:
  755. console_err.print(
  756. f"\n[red bold]{icon} Template Rendering Error[/red bold] [dim]({context})[/dim]"
  757. )
  758. else:
  759. console_err.print(f"\n[red bold]{icon} Template Rendering Error[/red bold]")
  760. console_err.print()
  761. # Display error message
  762. if error.file_path:
  763. console_err.print(
  764. f"[red]Error in file:[/red] [cyan]{error.file_path}[/cyan]"
  765. )
  766. if error.line_number:
  767. location = f"Line {error.line_number}"
  768. if error.column:
  769. location += f", Column {error.column}"
  770. console_err.print(f"[red]Location:[/red] {location}")
  771. console_err.print(
  772. f"[red]Message:[/red] {str(error.original_error) if error.original_error else str(error)}"
  773. )
  774. console_err.print()
  775. # Display code context if available
  776. if error.context_lines:
  777. console_err.print("[bold cyan]Code Context:[/bold cyan]")
  778. # Build the context text
  779. context_text = "\n".join(error.context_lines)
  780. # Display in a panel with syntax highlighting if possible
  781. file_ext = Path(error.file_path).suffix if error.file_path else ""
  782. if file_ext == ".j2":
  783. # Remove .j2 to get base extension for syntax highlighting
  784. base_name = Path(error.file_path).stem
  785. base_ext = Path(base_name).suffix
  786. lexer = "jinja2" if not base_ext else None
  787. else:
  788. lexer = None
  789. try:
  790. if lexer:
  791. syntax = Syntax(
  792. context_text, lexer, line_numbers=False, theme="monokai"
  793. )
  794. console_err.print(Panel(syntax, border_style="red", padding=(1, 2)))
  795. else:
  796. console_err.print(
  797. Panel(context_text, border_style="red", padding=(1, 2))
  798. )
  799. except Exception:
  800. # Fallback to plain panel if syntax highlighting fails
  801. console_err.print(
  802. Panel(context_text, border_style="red", padding=(1, 2))
  803. )
  804. console_err.print()
  805. # Display suggestions if available
  806. if error.suggestions:
  807. console_err.print("[bold yellow]Suggestions:[/bold yellow]")
  808. for i, suggestion in enumerate(error.suggestions, 1):
  809. bullet = IconManager.UI_BULLET
  810. console_err.print(f" [yellow]{bullet}[/yellow] {suggestion}")
  811. console_err.print()
  812. # Display variable context in debug mode
  813. if error.variable_context:
  814. console_err.print("[bold blue]Available Variables (Debug):[/bold blue]")
  815. var_list = ", ".join(sorted(error.variable_context.keys()))
  816. console_err.print(f"[dim]{var_list}[/dim]")
  817. console_err.print()