display.py 31 KB

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