pw_console: Run system commands in the repl

Typing !ls in the Python repl will run a shell command.

This is a ptpython feature and actually works today but will corrupt
the fullscreen TUI if you try it. This change lets the shell command
run in the user code thread with output appearing in the Python
Results window like any other Python command.

An additional 'Shell Output' log pane will appear and log all commands
with full ANSI color. Even long running processes like 'ninja -C out'
will work as expected.

Change-Id: I5a1f1df6b0915b9b7ccc30d31a2b48f500fe8db3
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/84749
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Erik Gilling <konkers@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index f83a428..625e381 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -76,6 +76,8 @@
 _LOG = logging.getLogger(__package__)
 _ROOT_LOG = logging.getLogger('')
 
+_SYSTEM_COMMAND_LOG = logging.getLogger('pw_console_system_command')
+
 MAX_FPS = 30
 MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0
 
@@ -250,6 +252,8 @@
         )
         self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme)
 
+        self.system_command_output_pane: Optional[LogPane] = None
+
         if self.prefs.swap_light_and_dark:
             self.toggle_light_theme()
 
@@ -962,6 +966,24 @@
             # loop.
             self.application.invalidate()
 
+    def setup_command_runner_log_pane(self) -> None:
+        if not self.system_command_output_pane is None:
+            return
+
+        self.system_command_output_pane = LogPane(application=self,
+                                                  pane_title='Shell Output')
+        self.system_command_output_pane.add_log_handler(_SYSTEM_COMMAND_LOG,
+                                                        level_name='INFO')
+        self.system_command_output_pane.log_view.log_store.formatter = (
+            logging.Formatter('%(message)s'))
+        self.system_command_output_pane.table_view = False
+        self.system_command_output_pane.show_pane = True
+        # Enable line wrapping
+        self.system_command_output_pane.toggle_wrap_lines()
+        # Blank right side toolbar text
+        self.system_command_output_pane._pane_subtitle = ' '  # pylint: disable=protected-access
+        self.window_manager.add_pane(self.system_command_output_pane)
+
     async def run(self, test_mode=False):
         """Start the prompt_toolkit UI."""
         if test_mode:
diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py
index 5d68578..c134982 100644
--- a/pw_console/py/pw_console/pw_ptpython_repl.py
+++ b/pw_console/py/pw_console/pw_ptpython_repl.py
@@ -17,7 +17,10 @@
 import functools
 import io
 import logging
+import os
 import sys
+import shlex
+import subprocess
 from typing import Iterable, Optional, TYPE_CHECKING
 
 from prompt_toolkit.buffer import Buffer
@@ -42,12 +45,17 @@
     from pw_console.repl_pane import ReplPane
 
 _LOG = logging.getLogger(__package__)
+_SYSTEM_COMMAND_LOG = logging.getLogger('pw_console_system_command')
 
 
 class MissingPtpythonBufferControl(Exception):
     """Exception for a missing ptpython BufferControl object."""
 
 
+def _user_input_is_a_shell_command(text: str) -> bool:
+    return text.startswith('!')
+
+
 class PwPtPythonRepl(ptpython.repl.PythonRepl):  # pylint: disable=too-many-instance-attributes
     """A ptpython repl class with changes to code execution and output related
     methods."""
@@ -237,6 +245,50 @@
         # Trigger a prompt_toolkit application redraw.
         self.repl_pane.application.application.invalidate()
 
+    async def _run_system_command(self, text, stdout_proxy,
+                                  _stdin_proxy) -> int:
+        """Run a shell command and print results to the repl."""
+        command = shlex.split(text)
+        returncode = None
+        env = os.environ.copy()
+        # Force colors in Pigweed subcommands and some terminal apps.
+        env['PW_USE_COLOR'] = '1'
+        env['CLICOLOR_FORCE'] = '1'
+
+        def _handle_output(output):
+            # Force tab characters to 8 spaces to prevent \t from showing in
+            # prompt_toolkit.
+            output = output.replace('\t', '        ')
+            # Strip some ANSI sequences that don't render.
+            output = output.replace('\x1b(B\x1b[m', '')
+            output = output.replace('\x1b[1m', '')
+            stdout_proxy.write(output)
+            _SYSTEM_COMMAND_LOG.info(output.rstrip())
+
+        with subprocess.Popen(command,
+                              env=env,
+                              stdout=subprocess.PIPE,
+                              stderr=subprocess.STDOUT,
+                              errors='replace') as proc:
+            # Print the command
+            _SYSTEM_COMMAND_LOG.info('')
+            _SYSTEM_COMMAND_LOG.info('$ %s', text)
+            while returncode is None:
+                if not proc.stdout:
+                    continue
+
+                # Check for one line and update.
+                output = proc.stdout.readline()
+                _handle_output(output)
+
+                returncode = proc.poll()
+
+            # Print any remaining lines.
+            for output in proc.stdout.readlines():
+                _handle_output(output)
+
+        return returncode
+
     async def _run_user_code(self, text, stdout_proxy, stdin_proxy):
         """Run user code and capture stdout+err.
 
@@ -256,7 +308,11 @@
 
         # Run user repl code
         try:
-            result = await self.run_and_show_expression_async(text)
+            if _user_input_is_a_shell_command(text):
+                result = await self._run_system_command(
+                    text[1:], stdout_proxy, stdin_proxy)
+            else:
+                result = await self.run_and_show_expression_async(text)
         finally:
             # Always restore original stdout and stderr
             sys.stdout = original_stdout
@@ -299,6 +355,10 @@
             temp_stdout.write(
                 'Error: Interactive help() is not compatible with this repl.')
 
+        # Pop open the system command log pane for shell commands.
+        if _user_input_is_a_shell_command(repl_input_text):
+            self.repl_pane.application.setup_command_runner_log_pane()
+
         # Execute the repl code in the the separate user_code thread loop.
         future = asyncio.run_coroutine_threadsafe(
             # This function will be executed in a separate thread.
diff --git a/pw_console/testing.rst b/pw_console/testing.rst
index 982ad11..78f50ed 100644
--- a/pw_console/testing.rst
+++ b/pw_console/testing.rst
@@ -416,8 +416,8 @@
      - |checkbox|
 
    * - 3
-     - | Click the :guilabel:`Python Input` window title
-     - Python Input pane is focused
+     - | Click the :guilabel:`Python Repl` window title
+     - Python Repl pane is focused
      - |checkbox|
 
    * - 4
@@ -451,7 +451,7 @@
      - |checkbox|
 
    * - 8
-     - | Click the :guilabel:`Python Input`
+     - | Click the :guilabel:`Python Repl`
        | window title
      - Repl pane is focused
      - |checkbox|
@@ -606,7 +606,7 @@
      - | Copy this text in your browser or
        | text editor to the system clipboard:
        | ``print('copy paste test!')``
-     - | Click the :guilabel:`Python Input` window title
+     - | Click the :guilabel:`Python Repl` window title
        | Press :kbd:`Ctrl-v`
        | ``print('copy paste test!')`` appears
        | after the prompt.
@@ -648,8 +648,8 @@
      - |checkbox|
 
    * - 10
-     - Click the :guilabel:`Python Input` window title
-     - Python Input is focused
+     - Click the :guilabel:`Python Repl` window title
+     - Python Repl is focused
      - |checkbox|
 
    * - 11
@@ -674,8 +674,8 @@
      - ✅
 
    * - 1
-     - | Click the :guilabel:`Python Input` window title
-     - Python Input pane is focused
+     - | Click the :guilabel:`Python Repl` window title
+     - Python Repl pane is focused
      - |checkbox|
 
    * - 2
@@ -688,7 +688,7 @@
        | (not all at once after a delay).
      - |checkbox|
 
-Python Input & Output
+Python Repl & Output
 ^^^^^^^^^^^^^^^^^^^^^
 
 .. list-table::
@@ -711,8 +711,8 @@
      - |checkbox|
 
    * - 3
-     - Click empty whitespace in the ``Python Input`` window
-     - Python Input pane is focused
+     - Click empty whitespace in the ``Python Repl`` window
+     - Python Repl pane is focused
      - |checkbox|
 
    * - 4
@@ -736,12 +736,30 @@
      - |checkbox|
 
    * - 6
-     - | With the cursor over the Python Output,
+     - | With the cursor over the Python Results,
        | use the mouse wheel to scroll up and down.
      - | The output window should be able to scroll all
        | the way to the beginning and end of the buffer.
      - |checkbox|
 
+   * - 7
+     - Click empty whitespace in the ``Python Repl`` window
+     - Python Repl pane is focused
+     - |checkbox|
+
+   * - 8
+     - | Enter the following text and press :kbd:`Enter` to run
+       | ``!ls``
+     - | 1. Shell output of running the ``ls`` command should appear in the
+       | results window.
+       | 2. A new log window pane should appear titled ``Shell Output``.
+       | 3. The Shell Output window should show the command that was run and the
+       | output:
+       | ``$ ls``
+       | ``activate.bat``
+       | ``activate.sh``
+     - |checkbox|
+
 Early Startup
 ^^^^^^^^^^^^^
 
@@ -826,11 +844,11 @@
 
    * - 9
      - | Press :kbd:`q`
-     - | The help window disappears and the Python Input is in focus.
+     - | The help window disappears and the Python Repl is in focus.
      - |checkbox|
 
    * - 10
-     - | Type some text into the Python Input.
+     - | Type some text into the Python Repl.
        | Press :kbd:`Home` or move the cursor to the
        | beginning of the text you just entered.
        | Press :kbd:`Ctrl-d`
@@ -838,7 +856,7 @@
      - |checkbox|
 
    * - 11
-     - | Press :kbd:`Ctrl-c` to clear the Python Input text
+     - | Press :kbd:`Ctrl-c` to clear the Python Repl text
        | Press :kbd:`Ctrl-d`
      - | The quit dialog appears.
      - |checkbox|