Repository: ansible/ansible-navigator (language: python)

Issue:
[ui] Notification and warning message boxes should allow for text with color per line



Discussion / hints:
![image](https://user-images.githubusercontent.com/18386516/139677787-d48cd0a1-6db8-4c2d-a71b-bed5110d28eb.png)
the hint line could be yellow and the "Could" line red
![image](https://user-images.githubusercontent.com/18386516/139677965-c99c2dfc-f2a1-45db-a5bd-4bad8cf554c7.png)

The goal, to make it look more like the stdout errors


Fix patch:
diff --git a/share/ansible_navigator/themes/terminal_colors.json b/share/ansible_navigator/themes/terminal_colors.json
index 138e2cec4..35472c326 100644
--- a/share/ansible_navigator/themes/terminal_colors.json
+++ b/share/ansible_navigator/themes/terminal_colors.json
@@ -1,18 +1,18 @@
 {
-"terminal.ansiBlack":"#090300",
-"terminal.ansiBlue":"#01A0E4",
-"terminal.ansiBrightBlack":"#5C5855",
-"terminal.ansiBrightBlue":"#01A0E4",
-"terminal.ansiBrightCyan":"#B5E4F4",
-"terminal.ansiBrightGreen":"#01A252",
-"terminal.ansiBrightMagenta":"#A16A94",
-"terminal.ansiBrightRed":"#DB2D20",
-"terminal.ansiBrightWhite":"#F7F7F7",
-"terminal.ansiBrightYellow":"#FDED02",
-"terminal.ansiCyan":"#B5E4F4",
-"terminal.ansiGreen":"#01A252",
-"terminal.ansiMagenta":"#A16A94",
-"terminal.ansiRed":"#DB2D20",
-"terminal.ansiWhite":"#A5A2A2",
-"terminal.ansiYellow":"#FDED02"
+    "terminal.ansiBlack": "#000000",
+    "terminal.ansiRed": "#cd3131",
+    "terminal.ansiGreen": "#0DBC79",
+    "terminal.ansiYellow": "#e5e510",
+    "terminal.ansiBlue": "#2472c8",
+    "terminal.ansiMagenta": "#bc3fbc",
+    "terminal.ansiCyan": "#11a8cd",
+    "terminal.ansiWhite": "#e5e5e5",
+    "terminal.ansiBrightBlack": "#666666",
+    "terminal.ansiBrightRed": "#f14c4c",
+    "terminal.ansiBrightGreen": "#23d18b",
+    "terminal.ansiBrightYellow": "#f5f543",
+    "terminal.ansiBrightBlue": "#d670d6",
+    "terminal.ansiBrightMagenta": "#d670d6",
+    "terminal.ansiBrightCyan": "#29b8db",
+    "terminal.ansiBrightWhite": "#e5e5e5"
 }
diff --git a/src/ansible_navigator/action_base.py b/src/ansible_navigator/action_base.py
index 125c67dd1..875f78754 100644
--- a/src/ansible_navigator/action_base.py
+++ b/src/ansible_navigator/action_base.py
@@ -18,9 +18,10 @@
 from .ui_framework import Interaction
 from .ui_framework import ui
 from .ui_framework import warning_notification
-from .utils.functions import ExitMessage
-from .utils.functions import ExitPrefix
-from .utils.functions import LogMessage
+from .ui_framework.form_utils import settings_notification
+from .utils.definitions import ExitMessage
+from .utils.definitions import ExitPrefix
+from .utils.definitions import LogMessage
 
 
 class ActionBase:
@@ -203,9 +204,7 @@ def _update_args(
                 self._logger.log(level=exit_msg.level, msg=exit_msg.message)
 
         if exit_messages:
-            warn_msg = ["Errors were encountered while parsing the last command."]
-            warn_msg.extend([f"{message.plain_text()}" for message in exit_messages])
-            warning = warning_notification(warn_msg)
+            warning = settings_notification(color=self._args.display_color, messages=exit_messages)
             self._interaction.ui.show_form(warning)
             return False
         return True
diff --git a/src/ansible_navigator/cli.py b/src/ansible_navigator/cli.py
index 5ed23f401..3fd99f260 100644
--- a/src/ansible_navigator/cli.py
+++ b/src/ansible_navigator/cli.py
@@ -26,9 +26,9 @@
 from .initialization import error_and_exit_early
 from .initialization import parse_and_update
 from .logger import setup_logger
-from .utils.functions import ExitMessage
-from .utils.functions import ExitPrefix
-from .utils.functions import LogMessage
+from .utils.definitions import ExitMessage
+from .utils.definitions import ExitPrefix
+from .utils.definitions import LogMessage
 from .utils.functions import clear_screen
 
 
@@ -127,10 +127,10 @@ def main():
     if exit_messages:
         args.entry("log_file").value.current = args.entry("log_file").value.default
         args.entry("log_level").value.current = "debug"
-        exit_msg = f"Configuration failed, using default log file location: {args.log_file}."
+        exit_msg = f"Configuration failed, using default log file location. ({args.log_file})"
         exit_msg += f" Log level set to {args.log_level}"
-        exit_messages.append(ExitMessage(message=exit_msg))
-        exit_msg = f"Review the hints and log file to see what went wrong: {args.log_file}"
+        exit_messages.append(ExitMessage(message=exit_msg, prefix=ExitPrefix.NOTE))
+        exit_msg = "Review the hints and log file to see what went wrong."
         exit_messages.append(ExitMessage(message=exit_msg, prefix=ExitPrefix.HINT))
 
     try:
diff --git a/src/ansible_navigator/command_runner/command_runner.py b/src/ansible_navigator/command_runner/command_runner.py
index 66ed6919e..7da14b510 100644
--- a/src/ansible_navigator/command_runner/command_runner.py
+++ b/src/ansible_navigator/command_runner/command_runner.py
@@ -9,7 +9,7 @@
 from typing import List
 from typing import Optional
 
-from ..utils.functions import LogMessage
+from ..utils.definitions import LogMessage
 
 
 PROCESSES = (multiprocessing.cpu_count() - 1) or 1
diff --git a/src/ansible_navigator/configuration_subsystem/configurator.py b/src/ansible_navigator/configuration_subsystem/configurator.py
index 342a90f3c..2750339be 100644
--- a/src/ansible_navigator/configuration_subsystem/configurator.py
+++ b/src/ansible_navigator/configuration_subsystem/configurator.py
@@ -8,9 +8,9 @@
 from typing import Tuple
 from typing import Union
 
-from ..utils.functions import ExitMessage
-from ..utils.functions import ExitPrefix
-from ..utils.functions import LogMessage
+from ..utils.definitions import ExitMessage
+from ..utils.definitions import ExitPrefix
+from ..utils.definitions import LogMessage
 from ..utils.functions import oxfordcomma
 from ..utils.functions import shlex_join
 from ..utils.json_schema import validate
@@ -89,6 +89,9 @@ def configure(self) -> Tuple[List[LogMessage], List[ExitMessage]]:
         shlex_joined = shlex_join(self._config.original_command)
         cmd_message = f"Command provided: '{shlex_joined}'"
         self._messages.append(LogMessage(level=logging.DEBUG, message=cmd_message))
+        warn_message = "Issues were found while applying the settings."
+        warning = ExitMessage(message=warn_message, prefix=ExitPrefix.WARNING)
+        command = ExitMessage(message=cmd_message, prefix=ExitPrefix.HINT)
 
         self._restore_original()
         self._apply_defaults()
@@ -96,7 +99,7 @@ def configure(self) -> Tuple[List[LogMessage], List[ExitMessage]]:
         self._apply_environment_variables()
         self._apply_cli_params()
         if self._exit_messages:
-            self._exit_messages.insert(0, ExitMessage(message=cmd_message))
+            self._exit_messages[0:0] = [warning, command]
             self._roll_back()
             return self._messages, self._exit_messages
 
@@ -106,7 +109,7 @@ def configure(self) -> Tuple[List[LogMessage], List[ExitMessage]]:
         self._post_process()
         self._check_choices()
         if self._exit_messages:
-            self._exit_messages.insert(0, ExitMessage(message=cmd_message))
+            self._exit_messages[0:0] = [warning, command]
             self._roll_back()
             return self._messages, self._exit_messages
 
diff --git a/src/ansible_navigator/configuration_subsystem/definitions.py b/src/ansible_navigator/configuration_subsystem/definitions.py
index f33b30ea6..ac57467e2 100644
--- a/src/ansible_navigator/configuration_subsystem/definitions.py
+++ b/src/ansible_navigator/configuration_subsystem/definitions.py
@@ -181,13 +181,13 @@ def invalid_choice(self) -> str:
         if isinstance(current, list):
             choices_str = oxfordcomma(choices, "and/or")
             current = oxfordcomma(current, "and")
-            prefix = f"{name} must be one or more of"
+            prefix = f"The setting '{name}' must be one or more of"
         else:
             choices_str = oxfordcomma(choices, "or")
-            prefix = f"{name} must be one of"
+            prefix = f"The setting '{name}' must be one of"
 
         source = self.value.source.value
-        msg = f"{prefix} {choices_str}, but set as '{current}' in {source}"
+        msg = f"{prefix} {choices_str}, but set as '{current}'. ({source})"
         return msg
 
     @property
diff --git a/src/ansible_navigator/configuration_subsystem/navigator_configuration.py b/src/ansible_navigator/configuration_subsystem/navigator_configuration.py
index 90804c9fc..8a17962c6 100644
--- a/src/ansible_navigator/configuration_subsystem/navigator_configuration.py
+++ b/src/ansible_navigator/configuration_subsystem/navigator_configuration.py
@@ -8,8 +8,8 @@
 from typing import Tuple
 from typing import Union
 
-from ..utils.functions import ExitMessage
-from ..utils.functions import LogMessage
+from ..utils.definitions import ExitMessage
+from ..utils.definitions import LogMessage
 from ..utils.functions import abs_user_path
 from ..utils.functions import get_share_directory
 from ..utils.functions import oxfordcomma
diff --git a/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py b/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py
index 54858d56f..143c83e1f 100644
--- a/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py
+++ b/src/ansible_navigator/configuration_subsystem/navigator_post_processor.py
@@ -15,9 +15,9 @@
 from typing import Tuple
 
 from ..utils.compatibility import zoneinfo
-from ..utils.functions import ExitMessage
-from ..utils.functions import ExitPrefix
-from ..utils.functions import LogMessage
+from ..utils.definitions import ExitMessage
+from ..utils.definitions import ExitPrefix
+from ..utils.definitions import LogMessage
 from ..utils.functions import abs_user_path
 from ..utils.functions import check_for_ansible
 from ..utils.functions import flatten_list
@@ -81,9 +81,9 @@ def _true_or_false(
         try:
             entry.value.current = str2bool(entry.value.current)
         except ValueError:
-            exit_msg = f"{entry.name} could not be converted to a boolean value,"
-            exit_msg += f" value was '{entry.value.current}' ({type(entry.value.current).__name__})"
-            exit_messages.append(ExitMessage(message=exit_msg))
+            # No error message here because the schema and/or choices will catch it
+            pass
+
         return messages, exit_messages
 
     @staticmethod
diff --git a/src/ansible_navigator/configuration_subsystem/utils.py b/src/ansible_navigator/configuration_subsystem/utils.py
index 3ce11e7b1..61b3bc60c 100644
--- a/src/ansible_navigator/configuration_subsystem/utils.py
+++ b/src/ansible_navigator/configuration_subsystem/utils.py
@@ -14,8 +14,8 @@
 
 from ..command_runner import Command
 from ..command_runner import CommandRunner
-from ..utils.functions import ExitMessage
-from ..utils.functions import LogMessage
+from ..utils.definitions import ExitMessage
+from ..utils.definitions import LogMessage
 from .definitions import Constants
 from .definitions import SettingsFileType
 
diff --git a/src/ansible_navigator/diagnostics.py b/src/ansible_navigator/diagnostics.py
index da773c881..c5796d612 100644
--- a/src/ansible_navigator/diagnostics.py
+++ b/src/ansible_navigator/diagnostics.py
@@ -25,8 +25,8 @@
 from .image_manager import introspect
 from .image_manager import introspector
 from .utils import ansi
-from .utils.functions import ExitMessage
-from .utils.functions import LogMessage
+from .utils.definitions import ExitMessage
+from .utils.definitions import LogMessage
 from .utils.functions import now_iso
 from .utils.functions import shlex_join
 from .utils.serialize import Loader
diff --git a/src/ansible_navigator/image_manager/puller.py b/src/ansible_navigator/image_manager/puller.py
index 8ec067633..939105599 100644
--- a/src/ansible_navigator/image_manager/puller.py
+++ b/src/ansible_navigator/image_manager/puller.py
@@ -8,9 +8,9 @@
 from typing import Union
 
 from ..configuration_subsystem import Constants
-from ..utils.functions import ExitMessage
-from ..utils.functions import ExitPrefix
-from ..utils.functions import LogMessage
+from ..utils.definitions import ExitMessage
+from ..utils.definitions import ExitPrefix
+from ..utils.definitions import LogMessage
 from ..utils.functions import shlex_join
 
 
diff --git a/src/ansible_navigator/initialization.py b/src/ansible_navigator/initialization.py
index 5c3557423..4b517d4c7 100644
--- a/src/ansible_navigator/initialization.py
+++ b/src/ansible_navigator/initialization.py
@@ -17,9 +17,11 @@
 from .configuration_subsystem import Configurator
 from .configuration_subsystem import Constants as C
 from .diagnostics import DiagnosticsCollector
-from .utils.functions import ExitMessage
-from .utils.functions import ExitPrefix
-from .utils.functions import LogMessage
+from .utils.definitions import ExitMessage
+from .utils.definitions import ExitMessages
+from .utils.definitions import ExitPrefix
+from .utils.definitions import LogMessage
+from .utils.functions import console_width
 from .utils.functions import environment_variable_is_file_path
 from .utils.functions import find_settings_file
 from .utils.key_value_store import KeyValueStore
@@ -30,8 +32,9 @@ def error_and_exit_early(exit_messages: List[ExitMessage]) -> NoReturn:
 
     :param exit_messages: List of all exit messages to be printed
     """
-    for exit_msg in exit_messages:
-        print(exit_msg)
+    color = "NO_COLOR" not in os.environ
+    printable = ExitMessages(messages=exit_messages).to_strings(color=color, width=console_width())
+    print("\n".join(printable), file=sys.stderr)
     sys.exit(1)
 
 
diff --git a/src/ansible_navigator/ui_framework/colorize.py b/src/ansible_navigator/ui_framework/colorize.py
index 0201b8b4e..1b7ffd911 100644
--- a/src/ansible_navigator/ui_framework/colorize.py
+++ b/src/ansible_navigator/ui_framework/colorize.py
@@ -25,6 +25,8 @@
 from .curses_defs import CursesLines
 from .curses_defs import RgbTuple
 from .curses_defs import SimpleLinePart
+from .ui_constants import Color
+from .ui_constants import Decoration
 
 
 CURSES_STYLES = {
@@ -312,6 +314,16 @@ def ansi_to_curses(line: str) -> CursesLine:
     :param line: A string with ansi colors
     :returns: A line ready for presentation in the TUI
     """
+    # pylint: disable=too-many-locals
+    if line == "":
+        line_part = CursesLinePart(
+            column=0,
+            string="",
+            color=Color.BLACK,
+            decoration=Decoration.NORMAL,
+        )
+        return CursesLine((line_part,))
+
     printable = []
     ansi_regex = re.compile(r"(\x1b\[[\d;]*m)")
     color_regex = re.compile(
diff --git a/src/ansible_navigator/ui_framework/field_curses_information.py b/src/ansible_navigator/ui_framework/field_curses_information.py
new file mode 100644
index 000000000..7223a4c3d
--- /dev/null
+++ b/src/ansible_navigator/ui_framework/field_curses_information.py
@@ -0,0 +1,54 @@
+"""An information field formatted as curses lines."""
+from dataclasses import dataclass
+from typing import Callable
+from typing import Optional
+from typing import Union
+
+from .curses_defs import CursesLines
+from .curses_window import Window
+from .form_handler_information import FormHandlerInformation
+from .sentinels import Unknown
+from .sentinels import unknown
+from .validators import FieldValidators
+
+
+@dataclass
+class FieldCursesInformation:
+    """An information field made of curses lines."""
+
+    name: str
+    information: CursesLines
+    current_error: str = ""
+    window_handler = FormHandlerInformation
+    valid: Union[bool, Unknown] = unknown
+    validator: Callable = FieldValidators.null
+    win: Optional[Window] = None
+
+    @property
+    def full_prompt(self) -> str:
+        """Return the max width string.
+
+        :returns: The max width string
+        """
+        return max(
+            [
+                max((line_part for line_part in line), key=lambda lp: len(lp.string)).string
+                for line in self.information
+            ],
+            key=len,
+        )
+
+    def validate(self, response: str) -> None:
+        # pylint: disable=unused-argument
+        """No validation required for information field.
+
+        :param response: The response to validate
+        """
+        self.valid = True
+
+    def conditional_validation(self, response: str) -> None:
+        """No conditional validation.
+
+        :param response: The response to validate
+        """
+        self.validate(response)
diff --git a/src/ansible_navigator/ui_framework/form.py b/src/ansible_navigator/ui_framework/form.py
index 0f7b1a054..c03af0edc 100644
--- a/src/ansible_navigator/ui_framework/form.py
+++ b/src/ansible_navigator/ui_framework/form.py
@@ -16,6 +16,7 @@
 from .curses_window import CursesWindow
 from .field_button import FieldButton
 from .field_checks import FieldChecks
+from .field_curses_information import FieldCursesInformation
 from .field_information import FieldInformation
 from .field_radio import FieldRadio
 from .field_text import FieldText
@@ -34,7 +35,7 @@ class Form:
     cancelled: bool = False
     fields: List = field(default_factory=list)
     submitted: bool = False
-    title = ""
+    title: str = ""
     title_color: int = 0
 
     _dict: Dict = field(default_factory=dict)
@@ -144,7 +145,7 @@ def _dimensions(self):
 
         height = 2  # title + horizontal line
         for form_field in self._form.fields:
-            if isinstance(form_field, FieldInformation):
+            if isinstance(form_field, (FieldCursesInformation, FieldInformation)):
                 height += len(form_field.information)
             if isinstance(form_field, (FieldWorking)):
                 height += len(form_field.messages)
@@ -172,7 +173,13 @@ def _generate_form(self) -> Tuple[Tuple[int, CursesLine], ...]:
 
             # pylint: disable=not-an-iterable
             # https://github.com/PyCQA/pylint/issues/2296
-            if isinstance(form_field, FieldInformation):
+
+            if isinstance(form_field, (FieldCursesInformation)):
+                for line in form_field.information:
+                    lines.append((self._line_number, line))
+                    self._line_number += 1
+
+            elif isinstance(form_field, FieldInformation):
                 information_lines = self._generate_information(form_field)
                 for line in information_lines:
                     lines.append((self._line_number, line))
@@ -309,7 +316,10 @@ def _generate_prompt(self, form_field) -> List[CursesLinePart]:
         return [cl_prompt, cl_default, cl_separator]
 
     def _generate_title(self) -> CursesLine:
-        line_part = CursesLinePart(0, self._form.title.capitalize(), self._form.title_color, 0)
+        title = self._form.title
+        if title.isupper() or not title[0].isupper():
+            title = title.capitalize()
+        line_part = CursesLinePart(0, title, self._form.title_color, 0)
         return CursesLine((line_part,))
 
     def present(self) -> Form:
diff --git a/src/ansible_navigator/ui_framework/form_utils.py b/src/ansible_navigator/ui_framework/form_utils.py
index 5f014d8a2..206ed3bce 100644
--- a/src/ansible_navigator/ui_framework/form_utils.py
+++ b/src/ansible_navigator/ui_framework/form_utils.py
@@ -8,7 +8,14 @@
 from typing import Dict
 from typing import List
 
+from ansible_navigator.utils.definitions import ExitMessage
+from ansible_navigator.utils.definitions import ExitMessages
+from ansible_navigator.utils.definitions import ExitPrefix
+from ..utils.functions import console_width
+from .colorize import ansi_to_curses
+from .curses_defs import CursesLines
 from .field_checks import FieldChecks
+from .field_curses_information import FieldCursesInformation
 from .field_information import FieldInformation
 from .field_option import FieldOption
 from .field_radio import FieldRadio
@@ -16,6 +23,7 @@
 from .field_working import FieldWorking
 from .form import Form
 from .form import FormType
+from .ui_constants import Color
 from .validators import FieldValidators
 
 
@@ -138,6 +146,36 @@ def nonblocking_notification(messages: List[str]) -> Form:
     return dict_to_form(form)
 
 
+def settings_notification(color: bool, messages: List[ExitMessage]) -> Form:
+    """Generate a warning notification for settings errors.
+
+    :param messages: List of messages to display
+    :returns: The form to display
+    """
+    # Take the initial warning if there is one
+    if messages[0].prefix is ExitPrefix.WARNING:
+        title = messages.pop(0).to_lines(color=False, width=console_width(), with_prefix=True)[0]
+    else:
+        title = "Warning"
+
+    formatted = ExitMessages(messages).to_strings(color=color, width=console_width())
+    formatted_curses = CursesLines(
+        tuple(ansi_to_curses(line) for line in formatted),
+    )
+    form = Form(
+        FormType.NOTIFICATION,
+        title=title,
+        title_color=Color.YELLOW,
+        fields=[
+            FieldCursesInformation(
+                name="settings_warning",
+                information=formatted_curses,
+            ),
+        ],
+    )
+    return form
+
+
 def warning_notification(messages: List[str]) -> Form:
     """generate a std warning notification"""
     messages = break_long_lines(messages)
diff --git a/src/ansible_navigator/utils/ansi.py b/src/ansible_navigator/utils/ansi.py
index b57fb378f..0e3d497c0 100644
--- a/src/ansible_navigator/utils/ansi.py
+++ b/src/ansible_navigator/utils/ansi.py
@@ -1,18 +1,6 @@
 """ANSI color codes and functions for terminal output."""
 
-
-class Color:
-    """Color constants."""
-
-    GREY = "\033[90m"
-    RED = "\033[91m"
-    GREEN = "\033[92m"
-    YELLOW = "\033[93m"
-    BLUE = "\033[94m"
-    MAGENTA = "\033[95m"
-    CYAN = "\033[96m"
-    WHITE = "\033[97m"
-    END = "\033[0m"
+from .definitions import Color
 
 
 def failed(color: bool, message: str):
diff --git a/src/ansible_navigator/utils/definitions.py b/src/ansible_navigator/utils/definitions.py
new file mode 100644
index 000000000..581adad48
--- /dev/null
+++ b/src/ansible_navigator/utils/definitions.py
@@ -0,0 +1,185 @@
+"""Object definitions related to utils."""
+
+import logging
+import textwrap
+
+from dataclasses import dataclass
+from dataclasses import field
+from enum import Enum
+from typing import List
+from typing import NamedTuple
+
+
+GOLDEN_RATIO = 1.61803398875
+
+
+class Color:
+    """Color constants."""
+
+    GREY = "\033[30m"
+    RED = "\033[31m"
+    GREEN = "\033[32m"
+    YELLOW = "\033[33m"
+    BLUE = "\033[34m"
+    MAGENTA = "\033[35m"
+    CYAN = "\033[36m"
+    WHITE = "\033[37m"
+    END = "\033[0m"
+
+
+class ExitPrefix(Enum):
+    """An exit message prefix."""
+
+    ERROR = "Error"
+    HINT = "Hint"
+    NOTE = "Note"
+    WARNING = "Warning"
+
+    @classmethod
+    def _longest_name(cls):
+        """Return the longest exit message prefix.
+
+        :returns: The longest exit message prefix
+        """
+        return max(len(member.value) for member in cls)
+
+    @classmethod
+    def longest_formatted(cls):
+        """Return the longest exit message prefix.
+
+        :returns: The longest exit message prefix
+        """
+        return max(len(str(member)) for member in cls)
+
+    def __str__(self):
+        """Return the exit message prefix as a string.
+
+        :returns: The exit message prefix as a string
+        """
+        return f"{' ' * (self._longest_name() - len(self.name))}{self.name.capitalize()}: "
+
+
+@dataclass
+class ExitMessage:
+    """An object to hold a message to present when exiting."""
+
+    #: The message that will be presented
+    message: str
+    #: The prefix for the message, used for formatting
+    prefix: ExitPrefix = ExitPrefix.ERROR
+
+    @property
+    def color(self):
+        """Return a color for the prefix.
+
+        :returns: The color for the prefix
+        """
+        color_mapping = {
+            ExitPrefix.ERROR: Color.RED,
+            ExitPrefix.HINT: Color.CYAN,
+            ExitPrefix.NOTE: Color.GREEN,
+            ExitPrefix.WARNING: Color.YELLOW,
+        }
+        return color_mapping[self.prefix]
+
+    @property
+    def level(self):
+        """Return a log level.
+
+        :returns: The log level
+        """
+        mapping = {
+            ExitPrefix.ERROR: logging.ERROR,
+            ExitPrefix.HINT: logging.INFO,
+            ExitPrefix.NOTE: logging.INFO,
+            ExitPrefix.WARNING: logging.WARNING,
+        }
+        return mapping[self.prefix]
+
+    def to_lines(self, color: bool, width: int, with_prefix: bool) -> List[str]:
+        """Output exit message to the console.
+
+        :param color: Whether to color the message
+        :param width: Constrain message to width
+        :param with_prefix: Whether to prefix the message
+        :returns: The exit message as a string
+        """
+        prefix_length = ExitPrefix.longest_formatted()
+        indent = " " * prefix_length
+
+        message = textwrap.fill(
+            self.message,
+            width=width,
+            break_on_hyphens=False,
+            initial_indent=str(self.prefix) if with_prefix else indent,
+            subsequent_indent=indent,
+        )
+        printable = []
+        lines = message.splitlines()
+
+        start_color = self.color if color else ""
+        end_color = Color.END if color else ""
+
+        for line in lines:
+            printable.append(f"{start_color}{line}{end_color}")
+
+        return printable
+
+
+@dataclass
+class ExitMessages:
+    """A mechanism to store multiple exit messages."""
+
+    messages: List[ExitMessage] = field(default_factory=list)
+
+    def to_strings(self, color: bool, width: int) -> List[str]:
+        """Output exit messages to the console.
+
+        :param color: Whether to color the message
+        :param width: Constrain messages to width
+        :returns: The exit messages as a list of strings
+        """
+        printable = []
+        new_section = True
+        for idx, message in enumerate(self.messages):
+            # Print the prefix if the next is different or a new section
+            if new_section:
+                # Use prefix, starting a new section
+                with_prefix = True
+            else:
+                try:
+                    # Prefix if previous is different
+                    with_prefix = self.messages[idx - 1].prefix != message.prefix
+                except IndexError:
+                    # Last message
+                    with_prefix = True
+            printable.extend(message.to_lines(color=color, with_prefix=with_prefix, width=width))
+
+            try:
+                next_prefix = self.messages[idx + 1].prefix
+
+                # Never break before a hint
+                if next_prefix is ExitPrefix.HINT:
+                    new_section = False
+                    continue
+
+                # Keep like items together
+                if message.prefix is next_prefix:
+                    new_section = False
+                    continue
+
+                # Start a new section
+                printable.append("")
+                new_section = True
+
+            except IndexError:
+                pass
+
+        return printable
+
+
+class LogMessage(NamedTuple):
+    """An object to hold a message destined for the logger."""
+
+    level: int
+    message: str
diff --git a/src/ansible_navigator/utils/functions.py b/src/ansible_navigator/utils/functions.py
index 39a0422fa..5a339d60c 100644
--- a/src/ansible_navigator/utils/functions.py
+++ b/src/ansible_navigator/utils/functions.py
@@ -12,14 +12,11 @@
 import sys
 import sysconfig
 
-from dataclasses import dataclass
-from enum import Enum
 from pathlib import Path
 from typing import Any
 from typing import Iterable
 from typing import List
 from typing import Mapping
-from typing import NamedTuple
 from typing import Optional
 from typing import Set
 from typing import Tuple
@@ -30,77 +27,14 @@
 from jinja2 import TemplateError
 
 from .compatibility import zoneinfo
+from .definitions import GOLDEN_RATIO
+from .definitions import ExitMessage
+from .definitions import LogMessage
 
 
 logger = logging.getLogger(__name__)
 
 
-class Colors(Enum):
-    """ANSI color codes"""
-
-    RED = "\033[0;31m"
-    YELLOW = "\033[33m"
-    END = "\033[0m"
-
-
-class ExitPrefix(Enum):
-    """An exit message prefix"""
-
-    ERROR = "ERROR"
-    HINT = "HINT"
-
-    @classmethod
-    def _longest(cls):
-        return max(len(member) for member in cls.__members__)
-
-    def __str__(self):
-        return f"{' ' * (self._longest() - len(self.name))}[{self.name}]: "
-
-
-@dataclass
-class ExitMessage:
-    """An object to hold a message to present when exiting."""
-
-    #: The message that will be presented
-    message: str
-    #: The prefix for the message, used for formatting
-    prefix: ExitPrefix = ExitPrefix.ERROR
-
-    @property
-    def color(self):
-        """return a color for the prefix"""
-        if self.prefix is ExitPrefix.ERROR:
-            return Colors.RED.value
-        if self.prefix is ExitPrefix.HINT:
-            return Colors.YELLOW.value
-        raise ValueError("Missing color mapping")
-
-    @property
-    def level(self):
-        """return a log level"""
-        if self.prefix is ExitPrefix.ERROR:
-            return logging.ERROR
-        if self.prefix is ExitPrefix.HINT:
-            return logging.INFO
-        raise ValueError("Missing logging level mapping")
-
-    def plain_text(self):
-        """Provide the error messages without ansi color."""
-        return f"{self.prefix}{self.message}"
-
-    def __str__(self):
-        if "NO_COLOR" in os.environ:
-            return self.plain_text()
-        return f"{self.color}{self.prefix}{self.message}{Colors.END.value}"
-
-
-class LogMessage(NamedTuple):
-    """An object to hold a message destined for the logger"""
-
-    level: int
-    message: str
-
-
 def oxfordcomma(listed, condition):
     """Format a list into a sentence"""
     listed = [f"'{str(entry)}'" for entry in listed]
@@ -159,6 +93,19 @@ def clear_screen() -> None:
             print()
 
 
+def console_width() -> int:
+    """Get a console width based on common screen widths.
+
+    :returns: The console width
+    """
+    width = shutil.get_terminal_size().columns
+    if width <= 80:
+        return width
+    if width <= 132:
+        return max(80, round_half_up(width / GOLDEN_RATIO))
+    return 132
+
+
 # TODO: Replace this with something type-safe.
 def dispatch(obj, replacements):
     """make the replacement based on type


Test patch:
diff --git a/tests/fixtures/integration/actions/collections/test_direct_interactive_ee.py/test/17.json b/tests/fixtures/integration/actions/collections/test_direct_interactive_ee.py/test/17.json
index 4efa053e4..072aaa4b0 100644
--- a/tests/fixtures/integration/actions/collections/test_direct_interactive_ee.py/test/17.json
+++ b/tests/fixtures/integration/actions/collections/test_direct_interactive_ee.py/test/17.json
@@ -4,20 +4,18 @@
     "comment": "Provide an invalid ee value",
     "additional_information": {
         "present": [
-            "Errors were encountered while parsing the last command"
+            "Issues were found while applying the settings"
         ],
         "absent": [],
         "compared_fixture": false
     },
     "output": [
-        "                                        Warning",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                        Errors were encountered while parsing the last command.",
-        "                                        [ERROR]: Command provided: 'collections --ee FFFFF'",
-        "                                        [ERROR]: execution_environment could not be converted to a boolean value, value was 'FFFFF' (str)",
-        "                                        [ERROR]: execution-environment must be one of 'true' or 'false', but set as 'FFFFF' in Command line",
-        "                                         [HINT]: Try again with '--ee true' or '--ee false'",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                                                                                                                      Ok"
+        "                                  Warning: Issues were found while applying the settings.",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                     Hint: Command provided: 'collections --ee FFFFF'",
+        "                                    Error: The setting 'execution-environment' must be one of 'true' or 'false', but set as 'FFFFF'. (Command line)",
+        "                                     Hint: Try again with '--ee true' or '--ee false'",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                                                                                                                                Ok"
     ]
 }
diff --git a/tests/fixtures/integration/actions/collections/test_direct_interactive_ee_with_volmount.py/test/17.json b/tests/fixtures/integration/actions/collections/test_direct_interactive_ee_with_volmount.py/test/17.json
index 4efa053e4..072aaa4b0 100644
--- a/tests/fixtures/integration/actions/collections/test_direct_interactive_ee_with_volmount.py/test/17.json
+++ b/tests/fixtures/integration/actions/collections/test_direct_interactive_ee_with_volmount.py/test/17.json
@@ -4,20 +4,18 @@
     "comment": "Provide an invalid ee value",
     "additional_information": {
         "present": [
-            "Errors were encountered while parsing the last command"
+            "Issues were found while applying the settings"
         ],
         "absent": [],
         "compared_fixture": false
     },
     "output": [
-        "                                        Warning",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                        Errors were encountered while parsing the last command.",
-        "                                        [ERROR]: Command provided: 'collections --ee FFFFF'",
-        "                                        [ERROR]: execution_environment could not be converted to a boolean value, value was 'FFFFF' (str)",
-        "                                        [ERROR]: execution-environment must be one of 'true' or 'false', but set as 'FFFFF' in Command line",
-        "                                         [HINT]: Try again with '--ee true' or '--ee false'",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                                                                                                                      Ok"
+        "                                  Warning: Issues were found while applying the settings.",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                     Hint: Command provided: 'collections --ee FFFFF'",
+        "                                    Error: The setting 'execution-environment' must be one of 'true' or 'false', but set as 'FFFFF'. (Command line)",
+        "                                     Hint: Try again with '--ee true' or '--ee false'",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                                                                                                                                Ok"
     ]
 }
diff --git a/tests/fixtures/integration/actions/collections/test_direct_interactive_noee.py/test/17.json b/tests/fixtures/integration/actions/collections/test_direct_interactive_noee.py/test/17.json
index 4efa053e4..072aaa4b0 100644
--- a/tests/fixtures/integration/actions/collections/test_direct_interactive_noee.py/test/17.json
+++ b/tests/fixtures/integration/actions/collections/test_direct_interactive_noee.py/test/17.json
@@ -4,20 +4,18 @@
     "comment": "Provide an invalid ee value",
     "additional_information": {
         "present": [
-            "Errors were encountered while parsing the last command"
+            "Issues were found while applying the settings"
         ],
         "absent": [],
         "compared_fixture": false
     },
     "output": [
-        "                                        Warning",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                        Errors were encountered while parsing the last command.",
-        "                                        [ERROR]: Command provided: 'collections --ee FFFFF'",
-        "                                        [ERROR]: execution_environment could not be converted to a boolean value, value was 'FFFFF' (str)",
-        "                                        [ERROR]: execution-environment must be one of 'true' or 'false', but set as 'FFFFF' in Command line",
-        "                                         [HINT]: Try again with '--ee true' or '--ee false'",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                                                                                                                      Ok"
+        "                                  Warning: Issues were found while applying the settings.",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                     Hint: Command provided: 'collections --ee FFFFF'",
+        "                                    Error: The setting 'execution-environment' must be one of 'true' or 'false', but set as 'FFFFF'. (Command line)",
+        "                                     Hint: Try again with '--ee true' or '--ee false'",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                                                                                                                                Ok"
     ]
 }
diff --git a/tests/fixtures/integration/actions/collections/test_welcome_interactive_ee.py/test/18.json b/tests/fixtures/integration/actions/collections/test_welcome_interactive_ee.py/test/18.json
index 0e228f7e4..bfd50e1db 100644
--- a/tests/fixtures/integration/actions/collections/test_welcome_interactive_ee.py/test/18.json
+++ b/tests/fixtures/integration/actions/collections/test_welcome_interactive_ee.py/test/18.json
@@ -4,20 +4,18 @@
     "comment": "Provide an invalid ee value",
     "additional_information": {
         "present": [
-            "Errors were encountered while parsing the last command"
+            "Issues were found while applying the settings"
         ],
         "absent": [],
         "compared_fixture": false
     },
     "output": [
-        "                                        Warning",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                        Errors were encountered while parsing the last command.",
-        "                                        [ERROR]: Command provided: 'collections --ee FFFFF'",
-        "                                        [ERROR]: execution_environment could not be converted to a boolean value, value was 'FFFFF' (str)",
-        "                                        [ERROR]: execution-environment must be one of 'true' or 'false', but set as 'FFFFF' in Command line",
-        "                                         [HINT]: Try again with '--ee true' or '--ee false'",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                                                                                                                      Ok"
+        "                                  Warning: Issues were found while applying the settings.",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                     Hint: Command provided: 'collections --ee FFFFF'",
+        "                                    Error: The setting 'execution-environment' must be one of 'true' or 'false', but set as 'FFFFF'. (Command line)",
+        "                                     Hint: Try again with '--ee true' or '--ee false'",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                                                                                                                                Ok"
     ]
 }
diff --git a/tests/fixtures/integration/actions/collections/test_welcome_interactive_noee.py/test/18.json b/tests/fixtures/integration/actions/collections/test_welcome_interactive_noee.py/test/18.json
index 0e228f7e4..bfd50e1db 100644
--- a/tests/fixtures/integration/actions/collections/test_welcome_interactive_noee.py/test/18.json
+++ b/tests/fixtures/integration/actions/collections/test_welcome_interactive_noee.py/test/18.json
@@ -4,20 +4,18 @@
     "comment": "Provide an invalid ee value",
     "additional_information": {
         "present": [
-            "Errors were encountered while parsing the last command"
+            "Issues were found while applying the settings"
         ],
         "absent": [],
         "compared_fixture": false
     },
     "output": [
-        "                                        Warning",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                        Errors were encountered while parsing the last command.",
-        "                                        [ERROR]: Command provided: 'collections --ee FFFFF'",
-        "                                        [ERROR]: execution_environment could not be converted to a boolean value, value was 'FFFFF' (str)",
-        "                                        [ERROR]: execution-environment must be one of 'true' or 'false', but set as 'FFFFF' in Command line",
-        "                                         [HINT]: Try again with '--ee true' or '--ee false'",
-        "                                        ───────────────────────────────────────────────────────────────────────────────────────────────────",
-        "                                                                                                                                      Ok"
+        "                                  Warning: Issues were found while applying the settings.",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                     Hint: Command provided: 'collections --ee FFFFF'",
+        "                                    Error: The setting 'execution-environment' must be one of 'true' or 'false', but set as 'FFFFF'. (Command line)",
+        "                                     Hint: Try again with '--ee true' or '--ee false'",
+        "                                  ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
+        "                                                                                                                                                Ok"
     ]
 }
diff --git a/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/0.json b/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/0.json
index 3a3e094e1..e631e6e0e 100644
--- a/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/0.json
+++ b/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/0.json
@@ -8,7 +8,7 @@
             "test_data_from_cli"
         ],
         "absent": [
-            "ERROR"
+            "Error"
         ],
         "compared_fixture": false
     },
diff --git a/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/1.json b/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/1.json
index 7272ce187..163fb46f3 100644
--- a/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/1.json
+++ b/tests/fixtures/integration/actions/exec/test_stdout_cli.py/test/1.json
@@ -6,18 +6,20 @@
         "present": [
             "bash",
             "test_data_from_cli",
-            "ERROR",
+            "Error",
             "requires execution environment support"
         ],
         "absent": [],
         "compared_fixture": false
     },
     "output": [
-        "[ERROR]: Command provided: 'exec 'echo test_data_from_cli' --ee False --ll debug --mode stdout'",
-        "[ERROR]: The 'exec' subcommand requires execution environment support.",
-        " [HINT]: Try again with '--ee true' to enable the use of an execution environment.",
-        "[ERROR]: Configuration failed, using default log file location: /home/user/github/ansible-navigator/ansible-navigator.log. Log level set to debug",
-        " [HINT]: Review the hints and log file to see what went wrong: /home/user/github/ansible-navigator/ansible-navigator.log",
+        "Warning: Issues were found while applying the settings.",
+        "   Hint: Command provided: 'exec 'echo test_data_from_cli' --ee False --ll debug --mode stdout'",
+        "  Error: The 'exec' subcommand requires execution environment support.",
+        "   Hint: Try again with '--ee true' to enable the use of an execution environment.",
+        "   Note: Configuration failed, using default log file location. (/home/user/github/ansible-navigator/ansible-navigator.log) Log",
+        "         level set to debug",
+        "   Hint: Review the hints and log file to see what went wrong.",
         "(venv) bash-5.1$"
     ]
 }
diff --git a/tests/integration/actions/collections/base.py b/tests/integration/actions/collections/base.py
index 791ea9e96..dd8a70d3f 100644
--- a/tests/integration/actions/collections/base.py
+++ b/tests/integration/actions/collections/base.py
@@ -47,7 +47,7 @@
     UiTestStep(
         user_input=":collections --ee FFFFF",
         comment="Provide an invalid ee value",
-        present=["Errors were encountered while parsing the last command"],
+        present=["Issues were found while applying the settings"],
         search_within_response=SearchFor.WARNING,
     ),
     # Dismiss the warning
diff --git a/tests/integration/actions/exec/test_stdout_cli.py b/tests/integration/actions/exec/test_stdout_cli.py
index 29622e78c..3e8209639 100644
--- a/tests/integration/actions/exec/test_stdout_cli.py
+++ b/tests/integration/actions/exec/test_stdout_cli.py
@@ -31,7 +31,7 @@ class ShellCommand(UiTestStep):
             execution_environment=True,
         ).join(),
         present=["bash", "test_data_from_cli"],
-        absent=["ERROR"],
+        absent=["Error"],
     ),
     ShellCommand(
         comment="exec echo without ee",
@@ -39,7 +39,7 @@ class ShellCommand(UiTestStep):
             cmdline="'echo test_data_from_cli'",
             execution_environment=False,
         ).join(),
-        present=["bash", "test_data_from_cli", "ERROR", "requires execution environment support"],
+        present=["bash", "test_data_from_cli", "Error", "requires execution environment support"],
     ),
     ShellCommand(
         comment="exec echo check path via shell",
diff --git a/tests/integration/settings_from_cli/test_json_schema_errors.py b/tests/integration/settings_from_cli/test_json_schema_errors.py
index 695c691e9..31cfb11a3 100644
--- a/tests/integration/settings_from_cli/test_json_schema_errors.py
+++ b/tests/integration/settings_from_cli/test_json_schema_errors.py
@@ -94,6 +94,9 @@ def test(data: Scenario, subtests: Any, tmp_path: Path):
         stdout=subprocess.PIPE,
         universal_newlines=True,
     )
+    long_stderr = "".join(
+        [line.strip(" ").replace("\n", " ") for line in proc_out.stderr.splitlines(keepends=True)],
+    )
     for value in data.messages:
         with subtests.test(msg=value, value=value):
-            assert value in proc_out.stdout
+            assert value in long_stderr
diff --git a/tests/unit/configuration_subsystem/post_processors/test_time_zone.py b/tests/unit/configuration_subsystem/post_processors/test_time_zone.py
index 174fd19d3..25d897516 100644
--- a/tests/unit/configuration_subsystem/post_processors/test_time_zone.py
+++ b/tests/unit/configuration_subsystem/post_processors/test_time_zone.py
@@ -145,6 +145,6 @@ def test_env_var(monkeypatch: pytest.MonkeyPatch, data: Scenario):
     _messages, exit_messages = configurator.configure()
 
     if data.exit_message_substr:
-        assert data.exit_message_substr in exit_messages[1].message
+        assert data.exit_message_substr in exit_messages[2].message
     else:
         assert application_configuration.entry("time_zone").value.current == data.expected
diff --git a/tests/unit/configuration_subsystem/test_ansible_config_parse.py b/tests/unit/configuration_subsystem/test_ansible_config_parse.py
index 46fbc587f..bab43ed1d 100644
--- a/tests/unit/configuration_subsystem/test_ansible_config_parse.py
+++ b/tests/unit/configuration_subsystem/test_ansible_config_parse.py
@@ -163,7 +163,7 @@ def test_invalid_configurator(ee_enabled, tmp_path: Path, monkeypatch: pytest.Mo
     assert application_configuration.internals.ansible_configuration.contents is Constants.NONE
     assert application_configuration.internals.ansible_configuration.path is Constants.NONE
     assert application_configuration.internals.ansible_configuration.text is Constants.NONE
-    assert "12345" in exit_messages[2].message
+    assert "12345" in exit_messages[3].message
 
 
 @pytest.mark.usefixtures("ansible_version")
diff --git a/tests/unit/configuration_subsystem/test_broken_settings.py b/tests/unit/configuration_subsystem/test_broken_settings.py
index 09fae9b0f..3d9a06cda 100644
--- a/tests/unit/configuration_subsystem/test_broken_settings.py
+++ b/tests/unit/configuration_subsystem/test_broken_settings.py
@@ -6,13 +6,13 @@
 def test_broken_settings_file(generate_config):
     """Ensure exit_messages generated for broken settings file"""
     response = generate_config(setting_file_name="ansible-navigator_broken.yml")
-    assert len(response.exit_messages) == 4, response.exit_messages
+    assert len(response.exit_messages) == 5, response.exit_messages
     error = "Settings file cannot be empty."
-    assert error in response.exit_messages[2].message
+    assert error in response.exit_messages[3].message
 
 
 def test_garbage_settings_file(generate_config):
     """Ensure exit_messages generated for garbage settings file"""
     response = generate_config(setting_file_name=os.path.abspath(__file__))
     error = "but failed to load it."
-    assert response.exit_messages[1].message.endswith(error), response.exit_messages
+    assert response.exit_messages[2].message.endswith(error), response.exit_messages
diff --git a/tests/unit/configuration_subsystem/test_invalid_params.py b/tests/unit/configuration_subsystem/test_invalid_params.py
index c0bbfb3f7..63158f980 100644
--- a/tests/unit/configuration_subsystem/test_invalid_params.py
+++ b/tests/unit/configuration_subsystem/test_invalid_params.py
@@ -16,9 +16,9 @@ def test_generate_argparse_error(generate_config):
     """Ensure exit_messages generated by argparse are caught"""
     params = "Sentinel"
     response = generate_config(params=params.split())
-    assert len(response.exit_messages) == 2
+    assert len(response.exit_messages) == 3
     exit_msg = "invalid choice: 'Sentinel'"
-    assert exit_msg in response.exit_messages[1].message
+    assert exit_msg in response.exit_messages[2].message
 
 
 @patch("shutil.which", return_value="/path/to/container_engine")