pw_console: Floating window pane plugin support

- Create the FloatingWindowPane class and add_floating_window_plugin()
  embed function.
- All WindowPane classes can implement a get_top_level_menus() fuction
  to return MenuItems for display in pw_consoles main menu bar.
- Convert 2048 to a proper floating window plugin with a
  get_top_level_menus() impl. Fixup watch_app.py to accomodate this
  change.

Minor changes:
- Populate the repl_pane's get_window_menu_options
- Replace some 'list' types with 'List'
- Switch jinja2 loader from Filesystem to DictLoader

Change-Id: I6247b39dd792edc5469b78dbfbe627b6e62fb71d
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/104464
Reviewed-by: Carlos Chinchilla <cachinchilla@google.com>
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_console/embedding.rst b/pw_console/embedding.rst
index 249ab84..07a9443 100644
--- a/pw_console/embedding.rst
+++ b/pw_console/embedding.rst
@@ -31,8 +31,8 @@
 Console embed instance. Typically, a console is started by creating a
 ``PwConsoleEmbed()`` instance, calling customization functions, then calling
 ``.embed()`` as shown in `Using embed()`_. Adding plugins functions similarly by
-calling ``add_top_toolbar``, ``add_bottom_toolbar`` or
-``add_window_plugin``. For example:
+calling ``add_top_toolbar``, ``add_bottom_toolbar``,
+``add_floating_window_plugin`` or ``add_window_plugin``. For example:
 
 .. code-block:: python
 
diff --git a/pw_console/plugins.rst b/pw_console/plugins.rst
index 2258e2d..e4e5cba 100644
--- a/pw_console/plugins.rst
+++ b/pw_console/plugins.rst
@@ -19,7 +19,8 @@
      background tasks.
 
 2. Enable the plugin before pw_console startup by calling ``add_window_plugin``,
-   ``add_top_toolbar`` or ``add_bottom_toolbar``. See the
+   ``add_floating_window_plugin``, ``add_top_toolbar`` or
+   ``add_bottom_toolbar``. See the
    :ref:`module-pw_console-embedding-plugins` section of the
    :ref:`module-pw_console-embedding` for an example.
 
@@ -127,9 +128,15 @@
 game of 2048.
 
 Similar to the ``ClockPane`` the ``Twenty48Pane`` class inherits from
-``WindowPane`` and ``PluginMixin``. Game keybindings are set within the
-``Twenty48Control`` class which is the ``FormattedTextControl`` widget that is
-in focus while playing.
+``PluginMixin`` to manage background tasks. With a few differences:
+
+- Uses ``FloatingWindowPane`` to create a floating window instead of a
+  standard tiled window.
+- Implements the ``get_top_level_menus`` function to create a new ``[2048]``
+  menu in Pigweed Console's own main menu bar.
+- Adds custom game keybindings which are set within the ``Twenty48Control``
+  class. That is the prompt_toolkit ``FormattedTextControl`` widget which
+  receives keyboard input when the game is in focus.
 
 The ``Twenty48Game`` class is separate from the user interface and handles
 managing the game state as well as printing the game board. The
diff --git a/pw_console/py/pw_console/__main__.py b/pw_console/py/pw_console/__main__.py
index 7c36cf9..97c4e77 100644
--- a/pw_console/py/pw_console/__main__.py
+++ b/pw_console/py/pw_console/__main__.py
@@ -27,6 +27,7 @@
 from pw_console.log_store import LogStore
 from pw_console.plugins.calc_pane import CalcPane
 from pw_console.plugins.clock_pane import ClockPane
+from pw_console.plugins.twenty48_pane import Twenty48Pane
 
 _LOG = logging.getLogger(__package__)
 _ROOT_LOG = logging.getLogger('')
@@ -135,6 +136,8 @@
         _ROOT_LOG.debug('Adding plugins...')
         console.add_window_plugin(ClockPane())
         console.add_window_plugin(CalcPane())
+        console.add_floating_window_plugin(
+            Twenty48Pane(include_resize_handle=False), left=4)
         _ROOT_LOG.debug('Starting prompt_toolkit full-screen application...')
 
     console.embed()
diff --git a/pw_console/py/pw_console/command_runner.py b/pw_console/py/pw_console/command_runner.py
index cc697db..4f4c98a 100644
--- a/pw_console/py/pw_console/command_runner.py
+++ b/pw_console/py/pw_console/command_runner.py
@@ -491,6 +491,7 @@
                 '[Help] > ',
                 # This focuses on a save dialog bor.
                 'Save/Export a copy',
+                '[Windows] > Floating ',
         ]:
             if command_text in self.selected_item_text:
                 close_dialog_first = True
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index 05d2af7..10c0b7e 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -22,9 +22,9 @@
 from pathlib import Path
 import sys
 from threading import Thread
-from typing import Any, Callable, Iterable, List, Optional, Tuple, Union
+from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
 
-from jinja2 import Environment, FileSystemLoader, make_logging_undefined
+from jinja2 import Environment, DictLoader, make_logging_undefined
 from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
 from prompt_toolkit.layout.menus import CompletionsMenu
 from prompt_toolkit.output import ColorDepth
@@ -61,13 +61,13 @@
 import pw_console.key_bindings
 from pw_console.log_pane import LogPane
 from pw_console.log_store import LogStore
-from pw_console.plugins.twenty48_pane import Twenty48Pane
 from pw_console.pw_ptpython_repl import PwPtPythonRepl
 from pw_console.python_logging import all_loggers
 from pw_console.quit_dialog import QuitDialog
 from pw_console.repl_pane import ReplPane
 import pw_console.style
 import pw_console.widgets.checkbox
+from pw_console.widgets import FloatingWindowPane
 import pw_console.widgets.mouse_handlers
 from pw_console.window_manager import WindowManager
 
@@ -137,6 +137,8 @@
         color_depth=None,
         extra_completers=None,
         prefs=None,
+        floating_window_plugins: Optional[List[Tuple[FloatingWindowPane,
+                                                     Dict]]] = None,
     ):
         self.prefs = prefs if prefs else ConsolePrefs()
         self.color_depth = get_default_colordepth(color_depth)
@@ -154,11 +156,16 @@
 
         local_vars = local_vars or global_vars
 
+        jinja_templates = {
+            t: importlib.resources.read_text('pw_console.templates', t)
+            for t in importlib.resources.contents('pw_console.templates')
+            if t.endswith('.jinja')
+        }
+
         # Setup the Jinja environment
         self.jinja_env = Environment(
             # Load templates automatically from pw_console/templates
-            loader=FileSystemLoader(
-                importlib.resources.files('pw_console.templates')),
+            loader=DictLoader(jinja_templates),
             # Raise errors if variables are undefined in templates
             undefined=make_logging_undefined(
                 logger=logging.getLogger(__package__), ),
@@ -214,8 +221,11 @@
         self.prefs_file_window.load_yaml_text(
             self.prefs.current_config_as_yaml())
 
-        self.game_2048 = Twenty48Pane(self, include_resize_handle=False)
-        self.game_2048.show_pane = False
+        self.floating_window_plugins = None
+        if floating_window_plugins:
+            self.floating_window_plugins = [
+                plugin for plugin, _ in floating_window_plugins
+            ]
 
         # Used for tracking which pane was in focus before showing help window.
         self.last_focused_pane = None
@@ -298,11 +308,15 @@
                 # Callable to get width
                 width=self.keybind_help_window.content_width,
             ),
-            Float(
-                content=self.game_2048,
-                top=3,
-                left=4,
-            ),
+        ]
+
+        if floating_window_plugins:
+            self.floats.extend([
+                Float(content=plugin_container, **float_args)
+                for plugin_container, float_args in floating_window_plugins
+            ])
+
+        self.floats.extend([
             # Completion menu that can overlap other panes since it lives in
             # the top level Float container.
             Float(
@@ -331,7 +345,7 @@
                 top=2,
                 left=2,
             ),
-        ]
+        ])
 
         # prompt_toolkit root container.
         self.root_container = MenuContainer(
@@ -392,9 +406,9 @@
         # Run the function for a particular menu item.
         return_value = function_to_run()
         # It's return value dictates if the main menu should close or not.
-        # - True: The main menu stays open. This is the default prompt_toolkit
+        # - False: The main menu stays open. This is the default prompt_toolkit
         #   menu behavior.
-        # - False: The main menu closes.
+        # - True: The main menu closes.
 
         # Update menu content. This will refresh checkboxes and add/remove
         # items.
@@ -599,13 +613,6 @@
                         'Themes',
                         children=themes_submenu,
                     ),
-                    MenuItem('Games',
-                             children=[
-                                 MenuItem(
-                                     '2048',
-                                     handler=self.game_2048.open_dialog,
-                                 ),
-                             ]),
                     MenuItem('-'),
                     MenuItem('Exit', handler=self.exit_console),
                 ],
@@ -700,7 +707,43 @@
             ),
         ]
 
-        window_menu = self.window_manager.create_window_menu()
+        window_menu_items = self.window_manager.create_window_menu_items()
+
+        floating_window_items = []
+        if self.floating_window_plugins:
+            floating_window_items.append(MenuItem('-', None))
+            floating_window_items.extend(
+                MenuItem(
+                    'Floating Window {index}: {title}'.format(
+                        index=pane_index + 1,
+                        title=pane.menu_title(),
+                    ),
+                    children=[
+                        MenuItem(
+                            '{check} Show/Hide Window'.format(
+                                check=pw_console.widgets.checkbox.
+                                to_checkbox_text(pane.show_pane, end='')),
+                            handler=functools.partial(
+                                self.run_pane_menu_option, pane.toggle_dialog),
+                        ),
+                    ] + [
+                        MenuItem(text,
+                                 handler=functools.partial(
+                                     self.run_pane_menu_option, handler))
+                        for text, handler in pane.get_window_menu_options()
+                    ],
+                ) for pane_index, pane in enumerate(
+                    self.floating_window_plugins))
+            window_menu_items.extend(floating_window_items)
+
+        window_menu = [MenuItem('[Windows]', children=window_menu_items)]
+
+        top_level_plugin_menus = []
+        for pane in self.window_manager.active_panes():
+            top_level_plugin_menus.extend(pane.get_top_level_menus())
+        if self.floating_window_plugins:
+            for pane in self.floating_window_plugins:
+                top_level_plugin_menus.extend(pane.get_top_level_menus())
 
         help_menu_items = [
             MenuItem(self.user_guide_window.menu_title(),
@@ -727,7 +770,8 @@
             ),
         ]
 
-        return file_menu + edit_menu + view_menu + window_menu + help_menu
+        return (file_menu + edit_menu + view_menu + top_level_plugin_menus +
+                window_menu + help_menu)
 
     def focus_main_menu(self):
         """Set application focus to the main menu."""
@@ -874,18 +918,20 @@
 
     def modal_window_is_open(self):
         """Return true if any modal window or dialog is open."""
+        floating_window_is_open = (self.keybind_help_window.show_window
+                                   or self.prefs_file_window.show_window
+                                   or self.user_guide_window.show_window
+                                   or self.quit_dialog.show_dialog
+                                   or self.command_runner.show_dialog)
+
         if self.app_help_text:
-            return (self.app_help_window.show_window
-                    or self.keybind_help_window.show_window
-                    or self.prefs_file_window.show_window
-                    or self.user_guide_window.show_window
-                    or self.quit_dialog.show_dialog or self.game_2048.show_pane
-                    or self.command_runner.show_dialog)
-        return (self.keybind_help_window.show_window
-                or self.prefs_file_window.show_window
-                or self.user_guide_window.show_window
-                or self.quit_dialog.show_dialog or self.game_2048.show_pane
-                or self.command_runner.show_dialog)
+            floating_window_is_open = (self.app_help_window.show_window
+                                       or floating_window_is_open)
+
+        floating_plugin_is_open = any(
+            plugin.show_pane for plugin in self.floating_window_plugins)
+
+        return floating_window_is_open or floating_plugin_is_open
 
     def exit_console(self):
         """Quit the console prompt_toolkit application UI."""
diff --git a/pw_console/py/pw_console/console_prefs.py b/pw_console/py/pw_console/console_prefs.py
index e8b891a..f5621f6 100644
--- a/pw_console/py/pw_console/console_prefs.py
+++ b/pw_console/py/pw_console/console_prefs.py
@@ -193,7 +193,7 @@
         self._config[name] = not existing_setting
 
     @property
-    def column_order(self) -> list:
+    def column_order(self) -> List:
         return self._config.get('column_order', [])
 
     def column_style(self,
@@ -223,7 +223,7 @@
         return self._config.get('windows', {})
 
     @property
-    def window_column_modes(self) -> list:
+    def window_column_modes(self) -> List:
         return list(column_type for column_type in self.windows.keys())
 
     @property
diff --git a/pw_console/py/pw_console/embed.py b/pw_console/py/pw_console/embed.py
index 60ef80a..e6dcddd 100644
--- a/pw_console/py/pw_console/embed.py
+++ b/pw_console/py/pw_console/embed.py
@@ -16,7 +16,7 @@
 import asyncio
 import logging
 from pathlib import Path
-from typing import Any, Dict, List, Iterable, Optional, Union
+from typing import Any, Dict, List, Iterable, Optional, Tuple, Union
 
 from prompt_toolkit.completion import WordCompleter
 
@@ -24,7 +24,11 @@
 from pw_console.get_pw_console_app import PW_CONSOLE_APP_CONTEXTVAR
 from pw_console.plugin_mixin import PluginMixin
 import pw_console.python_logging
-from pw_console.widgets import WindowPane, WindowPaneToolbar
+from pw_console.widgets import (
+    FloatingWindowPane,
+    WindowPane,
+    WindowPaneToolbar,
+)
 
 
 def _set_console_app_instance(plugin: Any, console_app: ConsoleApp) -> None:
@@ -124,6 +128,8 @@
         self.setup_python_logging_called = False
         self.hidden_by_default_windows: List[str] = []
         self.window_plugins: List[WindowPane] = []
+        self.floating_window_plugins: List[Tuple[FloatingWindowPane,
+                                                 Dict]] = []
         self.top_toolbar_plugins: List[WindowPaneToolbar] = []
         self.bottom_toolbar_plugins: List[WindowPaneToolbar] = []
 
@@ -135,6 +141,41 @@
         """
         self.window_plugins.append(window_pane)
 
+    def add_floating_window_plugin(self, window_pane: FloatingWindowPane,
+                                   **float_args) -> None:
+        """Include a custom floating window pane plugin.
+
+        This adds a FloatingWindowPane class to the pw_console UI. The first
+        argument should be the window to add and the remaining keyword arguments
+        are passed to the prompt_toolkit Float() class. This allows positioning
+        of the floating window. By default the floating window will be
+        centered. To anchor the window to a side or corner of the screen set the
+        ``left``, ``right``, ``top``, or ``bottom`` keyword args.
+
+        For example:
+
+        .. code-block:: python
+
+           from pw_console import PwConsoleEmbed
+
+           console = PwConsoleEmbed(...)
+           my_plugin = MyPlugin()
+           # Anchor this floating window 2 rows away from the top and 4 columns
+           # away from the left edge of the screen.
+           console.add_floating_window_plugin(my_plugin, top=2, left=4)
+
+        See all possible keyword args in the prompt_toolkit documentation:
+        https://python-prompt-toolkit.readthedocs.io/en/stable/pages/reference.html#prompt_toolkit.layout.Float
+
+        Args:
+            window_pane: Any instance of the FloatingWindowPane class.
+            left: Distance to the left edge of the screen
+            right: Distance to the right edge of the screen
+            top: Distance to the top edge of the screen
+            bottom: Distance to the bottom edge of the screen
+        """
+        self.floating_window_plugins.append((window_pane, float_args))
+
     def add_top_toolbar(self, toolbar: WindowPaneToolbar) -> None:
         """Include a toolbar plugin to display on the top of the screen.
 
@@ -251,6 +292,7 @@
             help_text=self.help_text,
             app_title=self.app_title,
             extra_completers=self.extra_completers,
+            floating_window_plugins=self.floating_window_plugins,
         )
         PW_CONSOLE_APP_CONTEXTVAR.set(self.console_app)  # type: ignore
         # Setup Python logging and log panes.
@@ -274,6 +316,10 @@
             _set_console_app_instance(toolbar, self.console_app)
             self.console_app.window_manager.add_bottom_toolbar(toolbar)
 
+        # Init floating window plugins.
+        for floating_window, _ in self.floating_window_plugins:
+            _set_console_app_instance(floating_window, self.console_app)
+
         # Rebuild prompt_toolkit containers, menu items, and help content with
         # any new plugins added above.
         self.console_app.refresh_layout()
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index eb4fbfd..7557a4b 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -16,7 +16,15 @@
 import functools
 import logging
 import re
-from typing import Any, List, Optional, Union, TYPE_CHECKING
+from typing import (
+    Any,
+    Callable,
+    List,
+    Optional,
+    TYPE_CHECKING,
+    Tuple,
+    Union,
+)
 
 from prompt_toolkit.application.current import get_app
 from prompt_toolkit.filters import (
@@ -548,7 +556,8 @@
         # Return log content control keybindings
         return [self.log_content_control.get_key_bindings()]
 
-    def get_all_menu_options(self) -> List:
+    def get_window_menu_options(
+            self) -> List[Tuple[str, Union[Callable, None]]]:
         """Return all menu options for the log pane."""
 
         options = [
diff --git a/pw_console/py/pw_console/log_view.py b/pw_console/py/pw_console/log_view.py
index 5ba56eb..31b7db8 100644
--- a/pw_console/py/pw_console/log_view.py
+++ b/pw_console/py/pw_console/log_view.py
@@ -738,7 +738,7 @@
         """Get pre-formatted table header."""
         return self.log_store.render_table_header()
 
-    def render_content(self) -> list:
+    def render_content(self) -> List:
         """Return logs to display on screen as a list of FormattedText tuples.
 
         This function determines when the log screen requires re-rendeing based
diff --git a/pw_console/py/pw_console/plugins/twenty48_pane.py b/pw_console/py/pw_console/plugins/twenty48_pane.py
index 872f856..832ce3e 100644
--- a/pw_console/py/pw_console/plugins/twenty48_pane.py
+++ b/pw_console/py/pw_console/plugins/twenty48_pane.py
@@ -14,25 +14,36 @@
 """Example Plugin that displays some dynamic content: a game of 2048."""
 
 from random import choice
-from typing import Any, Iterable, List, Tuple
+from typing import Iterable, List, Tuple, TYPE_CHECKING
 import time
 
 from prompt_toolkit.filters import has_focus
 from prompt_toolkit.formatted_text import StyleAndTextTuples
 from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 from prompt_toolkit.layout import (
+    AnyContainer,
     Dimension,
     FormattedTextControl,
+    HSplit,
     Window,
     WindowAlign,
     VSplit,
 )
 from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+from prompt_toolkit.widgets import MenuItem
 
-from pw_console.widgets import ToolbarButton, WindowPane, WindowPaneToolbar
+import pw_console.widgets.border
+from pw_console.widgets import (
+    FloatingWindowPane,
+    ToolbarButton,
+    WindowPaneToolbar,
+)
 from pw_console.plugin_mixin import PluginMixin
 from pw_console.get_pw_console_app import get_pw_console_app
 
+if TYPE_CHECKING:
+    from pw_console.console_app import ConsoleApp
+
 Twenty48Cell = Tuple[int, int, int]
 
 
@@ -347,7 +358,7 @@
         return NotImplemented
 
 
-class Twenty48Pane(WindowPane, PluginMixin):
+class Twenty48Pane(FloatingWindowPane, PluginMixin):
     """Example Pigweed Console plugin to play 2048.
 
     The Twenty48Pane is a WindowPane based plugin that displays an interactive
@@ -359,22 +370,16 @@
     For an example see:
     https://pigweed.dev/pw_console/embedding.html#adding-plugins
     """
-    def __init__(self,
-                 application: Any,
-                 include_resize_handle: bool = True,
-                 **kwargs):
+    def __init__(self, include_resize_handle: bool = True, **kwargs):
 
         super().__init__(pane_title='2048',
-                         height=Dimension(preferred=15),
-                         width=Dimension(preferred=48),
+                         height=Dimension(preferred=17),
+                         width=Dimension(preferred=50),
                          **kwargs)
-        # Reference to the parent pw_console app.
-        self.application = application
         self.game = Twenty48Game()
 
-        # Tracks the last focused container, to enable restoring focus after
-        # closing the dialog.
-        self.last_focused_pane = None
+        # Hide by default.
+        self.show_pane = False
 
         # Create a toolbar for display at the bottom of the 2048 window. It
         # will show the window title and buttons.
@@ -440,6 +445,25 @@
         # self.container is the root container that contains objects to be
         # rendered in the UI, one on top of the other.
         self.container = self._create_pane_container(
+            pw_console.widgets.border.create_border(
+                HSplit([
+                    # Vertical split content
+                    VSplit([
+                        # Left side will show the game board.
+                        self.twenty48_game_window,
+                        # Stats will be shown on the right.
+                        self.twenty48_stats_window,
+                    ]),
+                    # The bottom_toolbar is shown below the VSplit.
+                    self.bottom_toolbar,
+                ]),
+                title='2048',
+                border_style='class:command-runner-border',
+                # left_margin_columns=1,
+                # right_margin_columns=1,
+            ))
+
+        self.dialog_content: List[AnyContainer] = [
             # Vertical split content
             VSplit([
                 # Left side will show the game board.
@@ -449,7 +473,20 @@
             ]),
             # The bottom_toolbar is shown below the VSplit.
             self.bottom_toolbar,
+        ]
+        # Wrap the dialog content in a border
+        self.bordered_dialog_content = pw_console.widgets.border.create_border(
+            HSplit(self.dialog_content),
+            title='2048',
+            border_style='class:command-runner-border',
         )
+        # self.container is the root container that contains objects to be
+        # rendered in the UI, one on top of the other.
+        if include_resize_handle:
+            self.container = self._create_pane_container(*self.dialog_content)
+        else:
+            self.container = self._create_pane_container(
+                self.bordered_dialog_content)
 
         # This plugin needs to run a task in the background periodically and
         # uses self.plugin_init() to set which function to run, and how often.
@@ -462,25 +499,28 @@
             plugin_logger_name='pw_console_example_2048_plugin',
         )
 
-    def focus_self(self) -> None:
-        self.application.focus_on_container(self)
+    def get_top_level_menus(self) -> List[MenuItem]:
+        return [
+            MenuItem(
+                '[2048]',
+                children=[
+                    MenuItem('Example Top Level Menu',
+                             handler=None,
+                             disabled=True),
+                    # Menu separator
+                    MenuItem('-', None),
+                    MenuItem('Restart', handler=self.game.reset_game),
+                ],
+            ),
+        ]
 
-    def close_dialog(self) -> None:
-        """Close runner dialog box."""
-        self.show_pane = False
+    def pw_console_init(self, app: 'ConsoleApp') -> None:
+        """Set the Pigweed Console application instance.
 
-        # Restore original focus if possible.
-        if self.last_focused_pane:
-            self.application.focus_on_container(self.last_focused_pane)
-        else:
-            # Fallback to focusing on the main menu.
-            self.application.focus_main_menu()
-
-    def open_dialog(self) -> None:
-        self.show_pane = True
-        self.last_focused_pane = self.application.focused_window()
-        self.focus_self()
-        self.application.redraw_ui()
+        This function is called after the Pigweed Console starts up and allows
+        access to the user preferences. Prefs is required for creating new
+        user-remappable keybinds."""
+        self.application = app
 
     def _background_task(self) -> bool:
         """Function run in the background for the ClockPane plugin."""
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index 14ea92d..56aec25 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -27,6 +27,7 @@
     Optional,
     Tuple,
     TYPE_CHECKING,
+    Union,
 )
 
 from prompt_toolkit.filters import (
@@ -328,8 +329,21 @@
             'Show history.': ['F3'],
         }]
 
-    def get_all_menu_options(self):
-        return []
+    def get_window_menu_options(
+            self) -> List[Tuple[str, Union[Callable, None]]]:
+        return [
+            ('Python Input > Paste',
+             self.paste_system_clipboard_to_input_buffer),
+            ('Python Input > Copy or Clear', self.copy_or_clear_input_buffer),
+            ('Python Input > Run', self.run_code),
+            # Menu separator
+            ('-', None),
+            ('Python Output > Toggle Wrap lines',
+             self.toggle_wrap_output_lines),
+            ('Python Output > Copy All', self.copy_all_output_text),
+            ('Python Output > Copy Selection', self.copy_output_selection),
+            ('Python Output > Clear', self.clear_output_buffer),
+        ]
 
     def run_code(self):
         """Trigger a repl code execution on mouse click."""
diff --git a/pw_console/py/pw_console/widgets/__init__.py b/pw_console/py/pw_console/widgets/__init__.py
index 946e3af..71ec193 100644
--- a/pw_console/py/pw_console/widgets/__init__.py
+++ b/pw_console/py/pw_console/widgets/__init__.py
@@ -23,5 +23,9 @@
     to_checkbox_text,
 )
 from pw_console.widgets.mouse_handlers import on_click
-from pw_console.widgets.window_pane import WindowPane, WindowPaneHSplit
+from pw_console.widgets.window_pane import (
+    FloatingWindowPane,
+    WindowPane,
+    WindowPaneHSplit,
+)
 from pw_console.widgets.window_pane_toolbar import WindowPaneToolbar
diff --git a/pw_console/py/pw_console/widgets/window_pane.py b/pw_console/py/pw_console/widgets/window_pane.py
index ab2484a..17a0dfe 100644
--- a/pw_console/py/pw_console/widgets/window_pane.py
+++ b/pw_console/py/pw_console/widgets/window_pane.py
@@ -14,7 +14,7 @@
 """Window pane base class."""
 
 from abc import ABC
-from typing import Any, Optional, TYPE_CHECKING, Union
+from typing import Any, Callable, List, Optional, Tuple, TYPE_CHECKING, Union
 import functools
 
 from prompt_toolkit.layout.dimension import AnyDimension
@@ -27,6 +27,7 @@
     HSplit,
     walk,
 )
+from prompt_toolkit.widgets import MenuItem
 
 from pw_console.get_pw_console_app import get_pw_console_app
 
@@ -145,7 +146,7 @@
         object."""
         return self.container  # pylint: disable=no-member
 
-    def get_all_key_bindings(self) -> list:
+    def get_all_key_bindings(self) -> List:
         """Return keybinds for display in the help window.
 
         For example:
@@ -167,7 +168,8 @@
         # pylint: disable=no-self-use
         return []
 
-    def get_all_menu_options(self) -> list:
+    def get_window_menu_options(
+            self) -> List[Tuple[str, Union[Callable, None]]]:
         """Return menu options for the window pane.
 
         Should return a list of tuples containing with the display text and
@@ -176,6 +178,11 @@
         # pylint: disable=no-self-use
         return []
 
+    def get_top_level_menus(self) -> List[MenuItem]:
+        """Return MenuItems to be displayed on the main pw_console menu bar."""
+        # pylint: disable=no-self-use
+        return []
+
     def pane_resized(self) -> bool:
         """Return True if the current window size has changed."""
         return (self.last_pane_width != self.current_pane_width
@@ -209,3 +216,44 @@
             if container == child_container:
                 return True
         return False
+
+
+class FloatingWindowPane(WindowPane):
+    """The Pigweed Console FloatingWindowPane class."""
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Tracks the last focused container, to enable restoring focus after
+        # closing the dialog.
+        self.last_focused_pane = None
+
+    def close_dialog(self) -> None:
+        """Close runner dialog box."""
+        self.show_pane = False
+
+        # Restore original focus if possible.
+        if self.last_focused_pane:
+            self.application.focus_on_container(self.last_focused_pane)
+        else:
+            # Fallback to focusing on the main menu.
+            self.application.focus_main_menu()
+
+        self.application.update_menu_items()
+
+    def open_dialog(self) -> None:
+        self.show_pane = True
+        self.last_focused_pane = self.application.focused_window()
+        self.focus_self()
+        self.application.redraw_ui()
+
+        self.application.update_menu_items()
+
+    def toggle_dialog(self) -> bool:
+        if self.show_pane:
+            self.close_dialog()
+        else:
+            self.open_dialog()
+        # The focused window has changed. Return true so
+        # ConsoleApp.run_pane_menu_option does not set the focus to the main
+        # menu.
+        return True
diff --git a/pw_console/py/pw_console/window_manager.py b/pw_console/py/pw_console/window_manager.py
index 044751b..1098d7f 100644
--- a/pw_console/py/pw_console/window_manager.py
+++ b/pw_console/py/pw_console/window_manager.py
@@ -974,7 +974,7 @@
         # Focus on the first visible pane.
         self.focus_first_visible_pane()
 
-    def create_window_menu(self):
+    def create_window_menu_items(self) -> List[MenuItem]:
         """Build the [Window] menu for the current set of window lists."""
         root_menu_items = []
         for window_list_index, window_list in enumerate(self.window_lists):
@@ -1015,16 +1015,11 @@
                                  handler=functools.partial(
                                      self.application.run_pane_menu_option,
                                      handler))
-                        for text, handler in pane.get_all_menu_options()
+                        for text, handler in pane.get_window_menu_options()
                     ],
                 ) for pane_index, pane in enumerate(window_list.active_panes))
             if window_list_index + 1 < len(self.window_lists):
                 menu_items.append(MenuItem('-'))
             root_menu_items.extend(menu_items)
 
-        menu = MenuItem(
-            '[Windows]',
-            children=root_menu_items,
-        )
-
-        return [menu]
+        return root_menu_items
diff --git a/pw_console/py/repl_pane_test.py b/pw_console/py/repl_pane_test.py
index f6623b7..cc49972 100644
--- a/pw_console/py/repl_pane_test.py
+++ b/pw_console/py/repl_pane_test.py
@@ -127,12 +127,13 @@
                 repl_pane = app.repl_pane
 
                 # Mock update_output_buffer to track number of update calls
-                repl_pane.update_output_buffer = MagicMock(
+                repl_pane.update_output_buffer = MagicMock(  # type: ignore
                     wraps=repl_pane.update_output_buffer)
 
                 # Mock complete callback
-                pw_ptpython_repl.user_code_complete_callback = MagicMock(
-                    wraps=pw_ptpython_repl.user_code_complete_callback)
+                pw_ptpython_repl.user_code_complete_callback = (  # type: ignore
+                    MagicMock(
+                        wraps=pw_ptpython_repl.user_code_complete_callback))
 
                 # Repl done flag for tests
                 user_code_done = threading.Event()
diff --git a/pw_watch/py/pw_watch/watch_app.py b/pw_watch/py/pw_watch/watch_app.py
index 98432ec..b654f77 100644
--- a/pw_watch/py/pw_watch/watch_app.py
+++ b/pw_watch/py/pw_watch/watch_app.py
@@ -118,8 +118,9 @@
         self.ninja_log_pane.log_view.log_store.formatter = logging.Formatter(
             '%(message)s')
         self.ninja_log_pane.table_view = False
-        # Enable line wrapping
-        self.ninja_log_pane.toggle_wrap_lines()
+        # Disable line wrapping for improved error visibility.
+        if self.ninja_log_pane.wrap_lines:
+            self.ninja_log_pane.toggle_wrap_lines()
         # Blank right side toolbar text
         self.ninja_log_pane._pane_subtitle = ' '
         self.ninja_log_view = self.ninja_log_pane.log_view
@@ -147,7 +148,8 @@
 
         self.window_manager.add_pane(self.ninja_log_pane)
 
-        self.time_waster = Twenty48Pane(self)
+        self.time_waster = Twenty48Pane(include_resize_handle=True)
+        self.time_waster.application = self
         self.time_waster.show_pane = False
         self.window_manager.add_pane(self.time_waster)
 
@@ -200,7 +202,7 @@
             "Rebuild."
             self.run_build()
 
-        @key_bindings.add('c-g', filter=self.input_box_not_focused())
+        @key_bindings.add('c-t', filter=self.input_box_not_focused())
         def _pass_time(_event):
             "Rebuild."
             self.time_waster.show_pane = not self.time_waster.show_pane
@@ -306,6 +308,9 @@
         self.ninja_log_view.log_store.clear_logs()
         self.ninja_log_view._restart_filtering()  # pylint: disable=protected-access
         self.ninja_log_view.view_mode_changed()
+        # Re-enable follow if needed
+        if not self.ninja_log_view.follow:
+            self.ninja_log_view.toggle_follow()
 
     def run_build(self):
         """Manually trigger a rebuild."""