pw_console: Log pane scrolling and output ordering

- Log pane changes
  - Better fake log messages for easier scroll debugging
  - Fixed bug where last log message was not rendered
  - Clear logs function
  - Follow logs function
  - Log pane scrolling
      - Mouse wheel, j/k, up/down, page-up/down
  - Click anywhere in the log pane to focus on it.
  - Click log lines to select.

- Python pane changes:
  - Click anywhere in the repl pane to focus on it.
  - Shows commands and returned results in order of execution.
  - Long running commands will update the correct section.
  - If a task is executing 'Running...' is shown.
  - Ctrl-C will now:
    - Clear the input buffer if any text is there.
    - If input buffer is empty: cancel the last running task.
      Result in the output is then updated to 'Canceled'
  - Create a log when code is entered in the repl and when
    it finishes.
  - Stdout and stderr are captured in repl code execution and
    displayed in the output buffer.

Test: pw-console --loglevel debug --test-mode

No-Docs-Update-Reason: Scrolling implementation.
Change-Id: I83ad5ade5dedd5c3df27fdb7a76966c270efd739
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/44800
Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/py/pw_console/__main__.py b/pw_console/py/pw_console/__main__.py
index 0f85aac..cc80ac1 100644
--- a/pw_console/py/pw_console/__main__.py
+++ b/pw_console/py/pw_console/__main__.py
@@ -109,7 +109,10 @@
 
     default_loggers = []
     if args.test_mode:
-        default_loggers = [_LOG, logging.getLogger(FAKE_DEVICE_LOGGER_NAME)]
+        default_loggers = [
+            # _LOG,
+            logging.getLogger(FAKE_DEVICE_LOGGER_NAME)
+        ]
 
     embed(loggers=default_loggers,
           command_line_args=args,
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index c3fc817..bb1f40d 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -17,7 +17,6 @@
 import builtins
 import asyncio
 import logging
-import time
 from threading import Thread
 from typing import Iterable, Optional
 
@@ -60,6 +59,15 @@
 
 BAR_STYLE = 'bg:#fdd1ff #432445'
 
+pw_console_colors_base = {
+    'bg-main': '#ffffff',
+    'fg-main': '#000000',
+    'bg-dim': '#f8f8f8',
+    'fg-dim': '#282828',
+    'bg-alt': '#f0f0f0',
+    'fg-alt': '#505050',
+} # yapf: disable
+
 pw_console_styles = Style.from_dict({
     'top_toolbar_colored_background': 'bg:#c678dd #282c34',
     'top_toolbar': 'bg:#3e4452 #abb2bf',
@@ -91,6 +99,9 @@
     'keyhelp': BAR_STYLE,
 
     'help_window_content': 'bg:default default',
+
+    'cursor-line': 'bg:#3e4452 nounderline',
+    'selected-log-line': 'bg:#3e4452',
 }) # yapf: disable
 
 
@@ -204,10 +215,10 @@
         self.log_pane.log_container.setFormatter(formatter)
 
         self.pw_ptpython_repl = PwPtPythonRepl(
-            create_app=False,
             get_globals=lambda: global_vars,
             get_locals=lambda: local_vars,
         )
+
         self.repl_pane = ReplPane(
             application=self,
             python_repl=self.pw_ptpython_repl,
@@ -244,7 +255,7 @@
         self.key_bindings = create_key_bindings(self)
 
         self.root_container = MenuContainer(
-            body=self._create_root_hsplit(),
+            body=self._create_root_split(),
             menu_items=self.menu_items,
             floats=[
                 # Message Echo Area
@@ -268,10 +279,11 @@
         )
 
         self.layout: Layout = Layout(self.root_container,
-                                     focused_element=self.log_pane)
+                                     focused_element=self.repl_pane)
 
         self.application: Application = Application(
             layout=self.layout,
+            after_render=self.run_after_render_hooks,
             key_bindings=merge_key_bindings([
                 load_python_bindings(self.pw_ptpython_repl),
                 self.key_bindings,
@@ -287,6 +299,17 @@
         asyncio.set_event_loop(self.user_code_loop)
         self.user_code_loop.run_forever()
 
+    def run_after_render_hooks(self, *unused_args, **unused_kwargs):
+        # Don't query the terminal size after every render, it's very
+        # slow. Better to let prompt_toolkit handle this and instead save
+        # container sizes at render time.
+        # self.size = self.application.renderer.output.get_size()
+
+        # Run each active pane's after_render_hook if defined.
+        for pane in self.active_panes:
+            if hasattr(pane, 'after_render_hook'):
+                pane.after_render_hook()
+
     def start_user_code_thread(self):
         """Create a thread for running user code so the UI isn't blocked."""
         thread = Thread(target=self._user_code_thread_entry,
@@ -312,9 +335,15 @@
         help_window.generate_help_text()
         return help_window
 
-    def _create_root_hsplit(self):
+    def _create_root_split(self):
         if self.vertical_split:
-            self.active_pane_split = VSplit(self.active_panes)
+            self.active_pane_split = VSplit(
+                self.active_panes,
+                # Add a vertical separator between each active window pane.
+                padding=1,
+                padding_char='│',
+                padding_style='',
+            )
         else:
             self.active_pane_split = HSplit(self.active_panes)
 
@@ -330,11 +359,11 @@
         """Toggle visibility of the help window."""
         self.vertical_split = not self.vertical_split
         # For a root FloatContainer
-        # self.root_container.content = self._create_root_hsplit()
+        # self.root_container.content = self._create_root_split()
         # For a root MenuContainer
         self.root_container.container.content.children[
-            1] = self._create_root_hsplit()
-        self.application.invalidate()
+            1] = self._create_root_split()
+        self.redraw_ui()
 
     def toggle_help(self):
         """Toggle visibility of the help window."""
@@ -344,6 +373,10 @@
         """Quit the console prompt_toolkit application UI."""
         self.application.exit()
 
+    def redraw_ui(self):
+        """Redraw the prompt_toolkit UI."""
+        self.application.invalidate()
+
     async def run(self, test_mode=False):
         """Start the prompt_toolkit UI."""
         if test_mode:
@@ -367,9 +400,39 @@
 
     async def log_forever(self):
         """Test mode async log generator coroutine that runs forever."""
+        message_count = 0
+        # Sample log lines:
+        # Log message [=         ] # 291
+        # Log message [ =        ] # 292
+        # Log message [  =       ] # 293
+        # Log message [   =      ] # 294
+        # Log message [    =     ] # 295
+        # Log message [     =    ] # 296
+        # Log message [      =   ] # 297
+        # Log message [       =  ] # 298
+        # Log message [        = ] # 299
+        # Log message [         =] # 300
         while True:
-            await asyncio.sleep(1)
-            new_log_line = 'log_forever {}'.format(time.time())
+            await asyncio.sleep(2)
+            bar_size = 10
+            position = message_count % bar_size
+            bar_content = " " * (bar_size - position - 1) + "="
+            if position > 0:
+                bar_content = "=".rjust(position) + " " * (bar_size - position)
+            new_log_line = 'Log message [{}] # {}'.format(
+                bar_content, message_count)
+            if message_count % 10 == 0:
+                new_log_line += (" Lorem ipsum dolor sit amet, consectetur "
+                                 "adipiscing elit.") * 8
+            # Create a log line with linebreaks
+            # if message_count % 11 == 0:
+            #     new_log_line += inspect.cleandoc(""" [PYTHON] START
+            #         In []: import time;
+            #                 def t(s):
+            #                     time.sleep(s)
+            #                     return 't({}) seconds done'.format(s)""")
+
+            message_count += 1
             _FAKE_DEVICE_LOG.info(new_log_line)
 
     @property
diff --git a/pw_console/py/pw_console/key_bindings.py b/pw_console/py/pw_console/key_bindings.py
index 4eb5dc2..c7f3990 100644
--- a/pw_console/py/pw_console/key_bindings.py
+++ b/pw_console/py/pw_console/key_bindings.py
@@ -62,7 +62,7 @@
     @bindings.add('c-c', filter=has_focus(console_app.pw_ptpython_repl))
     def handle_ctrl_c(event):
         """Reset the python repl on Ctrl-c"""
-        console_app.pw_ptpython_repl.default_buffer.reset()
+        console_app.repl_pane.ctrl_c()
 
     @bindings.add('c-d', filter=has_focus(console_app.pw_ptpython_repl))
     def handle_ctrl_d(event):
diff --git a/pw_console/py/pw_console/log_container.py b/pw_console/py/pw_console/log_container.py
index aea1172..ca7839f 100644
--- a/pw_console/py/pw_console/log_container.py
+++ b/pw_console/py/pw_console/log_container.py
@@ -14,20 +14,31 @@
 """LogLine and LogContainer."""
 
 import logging
+import re
 import sys
 import time
 
 from collections import deque
 from dataclasses import dataclass
 from datetime import datetime
-from typing import List, Dict
+from typing import List, Dict, Optional
 
-from prompt_toolkit.formatted_text import ANSI
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.formatted_text import (
+    ANSI,
+    to_formatted_text,
+    fragment_list_width,
+)
 
-from pw_console.utils import human_readable_size
+from pw_console.utils import (
+    get_line_height,
+    human_readable_size,
+)
 
 _LOG = logging.getLogger(__package__)
 
+_ANSI_SEQUENCE_REGEX = re.compile(r'\x1b[^m]*m')
+
 
 @dataclass
 class LogLine:
@@ -42,11 +53,22 @@
     def get_fragments(self) -> List:
         """Return this log line as a list of FormattedText tuples."""
         # Manually make a FormattedText tuple, wrap in a list
-        # return [('class:bottom_toolbar_colored_text', self.record.msg + '\n')]
-        # Use ANSI, returns a list of tuples
+        # return [('class:bottom_toolbar_colored_text', self.record.msg)]
+        # Use ANSI, returns a list of FormattedText tuples.
+
+        # fragments = [('[SetCursorPosition]', '')]
+        # fragments += ANSI(self.formatted_log).__pt_formatted_text__()
+        # return fragments
+
+        # Add a trailing linebreak
         return ANSI(self.formatted_log + '\n').__pt_formatted_text__()
 
 
+def create_empty_log_message():
+    """Create an empty LogLine instance."""
+    return LogLine(record=logging.makeLogRecord({}), formatted_log='')
+
+
 class LogContainer(logging.Handler):
     """Class to hold many log events."""
 
@@ -54,21 +76,63 @@
     def __init__(self):
         self.logs: deque = deque()
         self.byte_size: int = 0
-        self.history_size: int = 1000
+        self.history_size: int = 1000000
         self.channel_counts: Dict = {}
+        self.channel_formatted_prefix_widths = {}
+        self.longest_channel_prefix_width = 0
+        self._last_start_index = 0
+        self._last_end_index = 0
+        self._current_start_index = 0
+        self._current_end_index = 0
         self.line_index = 0
+        self._window_height = 20
+        self._window_width = 80
+        self._last_line_wrap_count = 0
+        self._last_displayed_lines = []
         self.follow = True
-        self.log_content_control = None
+        self.log_pane = None
         self.pt_application = None
-        self._ui_update_frequency = 0.5
+        self._ui_update_frequency = 0.3
         self._last_ui_update_time = time.time()
+        self.clear_logs()
+        self.has_new_logs = True
+        self.line_fragment_cache = None
 
         super().__init__()
 
+    def get_current_line(self):
+        """Return the currently selected log event index."""
+        return self.line_index
+
+    def clear_logs(self):
+        """Erase all stored pane lines."""
+        self.logs = deque()
+        self.byte_size = 0
+        self.channel_counts = {}
+        self.channel_formatted_prefix_widths = {}
+        self.line_index = 0
+
+    def wrap_lines_enabled(self):
+        """Get the parent log pane wrap lines setting."""
+        if not self.log_pane:
+            return False
+        return self.log_pane.wrap_lines
+
+    def toggle_follow(self):
+        """Toggle auto line following."""
+        self.follow = not self.follow
+        if self.follow:
+            self.scroll_to_bottom()
+
     def get_total_count(self):
         """Total size of the logs container."""
         return len(self.logs)
 
+    def get_last_log_line_index(self):
+        """Last valid index of the logs."""
+        # Subtract 1 since self.logs is zero indexed.
+        return len(self.logs) - 1
+
     def get_channel_counts(self):
         """Return the seen channel log counts for the conatiner."""
         return ', '.join([
@@ -81,17 +145,30 @@
 
     def _append_log(self, record):
         """Add a new log event."""
-        self.logs.append(
-            LogLine(record=record, formatted_log=self.format(record)))
+        formatted_log = self.format(record)
+        self.logs.append(LogLine(record=record, formatted_log=formatted_log))
+        # Increment this logger count
         self.channel_counts[record.name] = self.channel_counts.get(
             record.name, 0) + 1
 
+        # Save a formatted prefix width if this is a new logger channel name.
+        if record.name not in self.channel_formatted_prefix_widths.keys():
+            # Delete ANSI escape sequences.
+            ansi_stripped_log = _ANSI_SEQUENCE_REGEX.sub('', formatted_log)
+            # Save the width of the formatted portion of the log message.
+            self.channel_formatted_prefix_widths[
+                record.name] = len(ansi_stripped_log) - len(record.msg)
+            # Set the max width of all known formats so far.
+            self.longest_channel_prefix_width = max(
+                self.channel_formatted_prefix_widths.values())
+
         self.byte_size += sys.getsizeof(self.logs[-1])
-        if len(self.logs) > self.history_size:
+        if self.get_total_count() > self.history_size:
             self.byte_size -= sys.getsizeof(self.logs.popleft())
 
+        self.has_new_logs = True
         if self.follow:
-            self.line_index = max(0, len(self.logs) - 1)
+            self.scroll_to_bottom()
 
     def _update_prompt_toolkit_ui(self):
         """Update Prompt Toolkit UI if a certain amount of time has passed."""
@@ -103,46 +180,218 @@
 
             # Trigger Prompt Toolkit UI redraw.
             # TODO(tonymd): Clean up this API
-            console_app = self.log_content_control.log_pane.application
+            console_app = self.log_pane.application
             if hasattr(console_app, 'application'):
                 # Thread safe way of sending a repaint trigger to the input
                 # event loop.
                 console_app.application.invalidate()
 
-    # logging.Handler emit() fuction. This is called by logging.Handler.handle()
-    # We don't implement handle() as it is done parent class with thread safety
-    # and filters applied.
+    def log_content_changed(self):
+        return self.has_new_logs
+
+    def get_cursor_position(self) -> Optional[Point]:
+        """Return the position of the cursor."""
+        fragment = "[SetCursorPosition]"
+        if not self.line_fragment_cache:
+            return Point(0, 0)
+        for row, line in enumerate(self.line_fragment_cache):
+            column = 0
+            for style_str, text, *_ in line:
+                if fragment in style_str:
+                    return Point(x=column, y=row)
+                column += len(text)
+        return Point(0, 0)
+
     def emit(self, record):
-        """Process log record."""
+        """Process a new log record.
+
+        This defines the logging.Handler emit() fuction which is called by
+        logging.Handler.handle() We don't implement handle() as it is done in
+        the parent class with thread safety and filters applied.
+        """
         self._append_log(record)
         self._update_prompt_toolkit_ui()
 
-    def draw(self) -> List:
-        """Return this log line as a FormattedTextControl."""
+    def scroll_to_top(self):
+        """Move selected index to the beginning."""
+        # Stop following so cursor doesn't jump back down to the bottom.
+        self.follow = False
+        self.line_index = 0
+
+    def scroll_to_bottom(self):
+        """Move selected index to the end."""
+        # Don't change following state like scroll_to_top.
+        self.line_index = max(0, self.get_last_log_line_index())
+
+    def scroll(self, lines):
+        """Scroll up or down by plus or minus lines.
+
+        This method is only called by user keybindings.
+        """
+        # If the user starts scrolling, stop auto following.
+        self.follow = False
+
+        # If scrolling to an index below zero, set to zero.
+        new_line_index = max(0, self.line_index + lines)
+        # If past the end, set to the last index of self.logs.
+        if new_line_index >= self.get_total_count():
+            new_line_index = self.get_last_log_line_index()
+        # Set the new selected line index.
+        self.line_index = new_line_index
+
+    def scroll_to_position(self, mouse_position: Point):
+        """Set the selected log line to the mouse_position."""
+        # If auto following don't move the cursor arbitrarily. That would stop
+        # following and position the cursor incorrectly.
+        if self.follow:
+            return
+
+        cursor_position = self.get_cursor_position()
+        if cursor_position:
+            scroll_amount = cursor_position.y - mouse_position.y
+            self.scroll(-1 * scroll_amount)
+
+    def scroll_up_one_page(self):
+        """Move the selected log index up by one window height."""
+        lines = 1
+        if self._window_height > 0:
+            lines = self._window_height
+        self.scroll(-1 * lines)
+
+    def scroll_down_one_page(self):
+        """Move the selected log index down by one window height."""
+        lines = 1
+        if self._window_height > 0:
+            lines = self._window_height
+        self.scroll(lines)
+
+    def scroll_down(self, lines=1):
+        """Move the selected log index down by one or more lines."""
+        self.scroll(lines)
+
+    def scroll_up(self, lines=1):
+        """Move the selected log index up by one or more lines."""
+        self.scroll(-1 * lines)
+
+    def get_log_window_indices(self,
+                               available_width=None,
+                               available_height=None):
+        """Get start and end index."""
+        self._last_start_index = self._current_start_index
+        self._last_end_index = self._current_end_index
+
         starting_index = 0
         ending_index = self.line_index
 
-        current_window_height = self.get_log_content_window_height()
-        if current_window_height > 0:
-            starting_index = max(0, self.line_index - current_window_height)
-            # Note: Line wrapping isn't taken into account for the visible log
-            # range when calculating starting_index and ending_index.
+        self._window_width = self.log_pane.current_log_pane_width
+        self._window_height = self.log_pane.current_log_pane_height
+        if available_width:
+            self._window_width = available_width
+        if available_height:
+            self._window_height = available_height
+
+        # If render info is available we use the last window height.
+        if self._window_height > 0:
+            # Window lines are zero indexed so subtract 1 from the height.
+            max_window_row_index = self._window_height - 1
+
+            starting_index = max(0, self.line_index - max_window_row_index)
+            # Use the current_window_height if line_index is less
+            ending_index = max(self.line_index, max_window_row_index)
+
+        if ending_index > self.get_last_log_line_index():
+            ending_index = self.get_last_log_line_index()
+
+        # Save start and end index.
+        self._current_start_index = starting_index
+        self._current_end_index = ending_index
+        self.has_new_logs = False
+
+        return starting_index, ending_index
+
+    def draw(self) -> List:
+        """Return log lines as a list of FormattedText tuples."""
+        # If we have no logs add one with at least a single space character for
+        # the cursor to land on. Otherwise the cursor will be left on the line
+        # above the log pane container.
+        if self.get_total_count() == 0:
+            # No style specified.
+            return [('', ' \n')]
+
+        starting_index, ending_index = self.get_log_window_indices()
+
+        window_width = self._window_width
+        total_used_lines = 0
+        self.line_fragment_cache = deque()
+        # Since range() is not inclusive use ending_index + 1.
+        # for i in range(starting_index, ending_index + 1):
+        # From the ending_index to the starting index in reverse:
+        for i in range(ending_index, starting_index - 1, -1):
+            # If we are past the last valid index.
+            if i > self.get_last_log_line_index():
+                break
+
+            line_fragments = self.logs[i].get_fragments()
+
+            # Get the width of this line.
+            fragment_width = fragment_list_width(line_fragments)
+            # Get the line height respecting line wrapping.
+            line_height = 1
+            if self.wrap_lines_enabled() and (fragment_width > window_width):
+                line_height = get_line_height(
+                    fragment_width, window_width,
+                    self.longest_channel_prefix_width)
+
+            # Keep track of how many lines is used
+            used_lines = 0
+            used_lines += line_height
+
+            # Count the number of line breaks included in the log line.
+            line_breaks = self.logs[i].record.msg.count('\n')
+            used_lines += line_breaks
+
+            # If this is the selected line apply a style class for highlighting.
+            if i == self.line_index:
+                # Set the cursor to this line
+                line_fragments = [('[SetCursorPosition]', '')] + line_fragments
+                # Compute the number of trailing empty characters
+
+                # Calculate the number of spaces to add at the end.
+                empty_characters = window_width - fragment_width
+
+                # If wrapping is enabled the width of the line prefix needs to
+                # be accounted for.
+                if self.wrap_lines_enabled() and (fragment_width >
+                                                  window_width):
+                    total_width = line_height * window_width
+                    content_width = (self.longest_channel_prefix_width *
+                                     (line_height - 1) + fragment_width)
+                    empty_characters = total_width - content_width
+
+                if empty_characters > 0:
+                    line_fragments[-1] = ('', ' ' * empty_characters + '\n')
+
+                line_fragments = to_formatted_text(
+                    line_fragments, style='class:selected-log-line')
+
+            self.line_fragment_cache.appendleft(line_fragments)
+            total_used_lines += used_lines
+            # If we have used more lines than available, stop adding new ones.
+            if total_used_lines > self._window_height:
+                break
 
         fragments = []
-        for i in range(starting_index, ending_index):
-            for fragment in self.logs[i].get_fragments():
+        for line_fragments in self.line_fragment_cache:
+            # Append all FormattedText tuples for this line.
+            for fragment in line_fragments:
                 fragments.append(fragment)
+
+        # Strip off any trailing line breaks
+        last_fragment = fragments[-1]
+        fragments[-1] = (last_fragment[0], last_fragment[1].rstrip('\n'))
+
         return fragments
 
-    def get_log_content_window_height(self) -> int:
-        """Get the height of the log window."""
-        # Get height of the parent LogPane from the last time prompt_toolkit
-        # rendered the content.
-        log_window = self.log_content_control.log_pane.log_display_window
-        if log_window.render_info:
-            return log_window.render_info.window_height
-        return 0
-
-    def set_log_content_control(self, log_content_control):
-        """Set the parent LogContentControll instance."""
-        self.log_content_control = log_content_control
+    def set_log_pane(self, log_pane):
+        """Set the parent LogPane instance."""
+        self.log_pane = log_pane
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index 80d0192..c0585b9 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -16,25 +16,28 @@
 import logging
 from dataclasses import dataclass, field
 from functools import partial
-from typing import Any, List
+from typing import Any, List, Optional
 
-from IPython.lib.pretty import pretty  # type: ignore
 from prompt_toolkit.application.current import get_app
-# from prompt_toolkit.data_structures import Point
 from prompt_toolkit.filters import (
     Condition,
     has_focus,
 )
+from prompt_toolkit.formatted_text import to_formatted_text
 from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 from prompt_toolkit.layout import (
     ConditionalContainer,
     Dimension,
+    Float,
+    FloatContainer,
     FormattedTextControl,
     HSplit,
     ScrollOffsets,
     VSplit,
     Window,
     WindowAlign,
+    UIContent,
+    VerticalAlign,
 )
 from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 
@@ -43,10 +46,37 @@
 _LOG = logging.getLogger(__package__)
 
 
+class LogPaneLineInfoBar(ConditionalContainer):
+    """One line bar for showing current and total log lines."""
+    @staticmethod
+    def get_tokens(log_pane):
+        """Return formatted text tokens for display."""
+        tokens = ' Line {} / {} '.format(
+            log_pane.log_container.get_current_line() + 1,
+            log_pane.log_container.get_total_count(),
+        )
+        return [('', tokens)]
+
+    def __init__(self, log_pane):
+        info_bar_control = FormattedTextControl(
+            partial(LogPaneLineInfoBar.get_tokens, log_pane))
+        info_bar_window = Window(content=info_bar_control,
+                                 align=WindowAlign.RIGHT,
+                                 dont_extend_width=True)
+
+        super().__init__(
+            VSplit([info_bar_window],
+                   height=1,
+                   style='class:bottom_toolbar',
+                   align=WindowAlign.RIGHT),
+            # Hide line info if auto-following logs.
+            filter=Condition(lambda: not log_pane.log_container.follow))
+
+
 class LogPaneBottomToolbarBar(ConditionalContainer):
     """One line toolbar for display at the bottom of the LogPane."""
     @staticmethod
-    def mouse_handler(log_pane, mouse_event: MouseEvent):
+    def mouse_handler_focus(log_pane, mouse_event: MouseEvent):
         """Focus this pane on click."""
         if mouse_event.event_type == MouseEventType.MOUSE_UP:
             log_pane.application.application.layout.focus(log_pane)
@@ -54,22 +84,56 @@
         return NotImplemented
 
     @staticmethod
+    def mouse_handler_toggle_wrap_lines(log_pane, mouse_event: MouseEvent):
+        """Toggle wrap lines on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.toggle_wrap_lines()
+            return None
+        return NotImplemented
+
+    @staticmethod
+    def mouse_handler_clear_history(log_pane, mouse_event: MouseEvent):
+        """Clear history on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.clear_history()
+            return None
+        return NotImplemented
+
+    @staticmethod
+    def mouse_handler_toggle_follow(log_pane, mouse_event: MouseEvent):
+        """Toggle follow on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.toggle_follow()
+            return None
+        return NotImplemented
+
+    @staticmethod
     def get_center_text_tokens(log_pane):
         """Return formatted text tokens for display in the center part of the
         toolbar."""
-        mouse_handler = partial(LogPaneBottomToolbarBar.mouse_handler,
-                                log_pane)
+        focus = partial(LogPaneBottomToolbarBar.mouse_handler_focus, log_pane)
+        toggle_wrap_lines = partial(
+            LogPaneBottomToolbarBar.mouse_handler_toggle_wrap_lines, log_pane)
+        clear_history = partial(
+            LogPaneBottomToolbarBar.mouse_handler_clear_history, log_pane)
+        toggle_follow = partial(
+            LogPaneBottomToolbarBar.mouse_handler_toggle_follow, log_pane)
         if has_focus(log_pane.__pt_container__())():
             return [
-                ('', ' [FOCUSED] ', mouse_handler),
-                ('class:keybind', 'w', mouse_handler),
+                ('', ' [FOCUSED]'),
+                ('', ' '),
+                ('class:keybind', 'w', toggle_wrap_lines),
                 # TODO: Indicate wrap on/off
-                ('class:keyhelp', ':Wrap ', mouse_handler),
+                ('class:keyhelp', ':Wrap', toggle_wrap_lines),
+                ('', ' '),
+                ('class:keybind', 'C', clear_history),
+                ('class:keyhelp', ':Clear', clear_history),
+                ('', ' '),
+                ('class:keybind', 'f', toggle_follow),
+                ('class:keyhelp', ':Follow', toggle_follow),
             ]
         return [
-            # TODO: Clicking on the actual logs status bar doesn't focus; only
-            # when clicking on the log content itself. Fix this.
-            ('class:keyhelp', ' [click to focus] ', mouse_handler),
+            ('class:keyhelp', ' [click to focus] ', focus),
         ]
 
     def __init__(self, log_pane):
@@ -80,8 +144,9 @@
                         content=FormattedTextControl(
                             # Logs [FOCUSED] w:Toggle wrap
                             [('class:logo', ' Logs ',
-                              partial(LogPaneBottomToolbarBar.mouse_handler,
-                                      log_pane))]),
+                              partial(
+                                  LogPaneBottomToolbarBar.mouse_handler_focus,
+                                  log_pane))]),
                         align=WindowAlign.LEFT,
                         dont_extend_width=True),
                     Window(content=FormattedTextControl(
@@ -106,13 +171,23 @@
 class LogContentControl(FormattedTextControl):
     """LogPane prompt_toolkit UIControl for displaying LogContainer lines."""
     @staticmethod
-    def indent_wrapped_pw_log_format_line(unused_lineno, wrap_count):
+    def indent_wrapped_pw_log_format_line(log_pane, line_number, wrap_count):
         """Indent wrapped lines to match pw_cli timestamp & level formatter."""
         if wrap_count == 0:
             return None
-        # Example
-        # Log: '20210418 12:32:23 INF '
-        return '                      '
+
+        prefix = ' ' * log_pane.log_container.longest_channel_prefix_width
+
+        # If this line matches the selected log line, highlight it.
+        if line_number == log_pane.log_container.get_cursor_position().y:
+            return to_formatted_text(prefix, style='class:selected-log-line')
+        return prefix
+
+    def create_content(self, width: int, height: Optional[int]) -> UIContent:
+        # Save redered height
+        if height:
+            self.log_pane.last_log_content_height += height
+        return super().create_content(width, height)
 
     def __init__(self, log_pane, *args, **kwargs):
         self.log_pane = log_pane
@@ -125,58 +200,103 @@
             """Toggle log line wrapping."""
             self.log_pane.toggle_wrap_lines()
 
+        @key_bindings.add('C')
+        def _clear_history(_event: KeyPressEvent) -> None:
+            """Toggle log line wrapping."""
+            self.log_pane.clear_history()
+
+        @key_bindings.add('g')
+        def _scroll_to_top(_event: KeyPressEvent) -> None:
+            """Scroll to top."""
+            self.log_pane.log_container.scroll_to_top()
+
+        @key_bindings.add('G')
+        def _scroll_to_bottom(_event: KeyPressEvent) -> None:
+            """Scroll to bottom."""
+            self.log_pane.log_container.scroll_to_bottom()
+
+        @key_bindings.add('f')
+        def _toggle_follow(_event: KeyPressEvent) -> None:
+            """Toggle log line following."""
+            self.log_pane.toggle_follow()
+
         @key_bindings.add('up')
         @key_bindings.add('k')
-        def _up(event: KeyPressEvent) -> None:
-            """Select next log line."""
-            _LOG.debug(pretty(self) + ' ' + pretty(event))
-            # self._selected_index = max(0, self._selected_index - 1)
+        def _up(_event: KeyPressEvent) -> None:
+            """Select previous log line."""
+            self.log_pane.log_container.scroll_up()
 
         @key_bindings.add('down')
         @key_bindings.add('j')
-        def _down(event: KeyPressEvent) -> None:
-            """Select previous log line."""
-            _LOG.debug(pretty(self) + ' ' + pretty(event))
-            # self._selected_index = min(len(self.values) - 1,
-            #                            self._selected_index + 1)
+        def _down(_event: KeyPressEvent) -> None:
+            """Select next log line."""
+            self.log_pane.log_container.scroll_down()
 
         @key_bindings.add('pageup')
-        def _pageup(event: KeyPressEvent) -> None:
+        def _pageup(_event: KeyPressEvent) -> None:
             """Scroll the logs up by one page."""
-            _LOG.debug(pretty(self) + ' ' + pretty(event))
-            # w = event.app.layout.current_window
-            # if w.render_info:
-            #     self._selected_index = max(
-            #         0, self._selected_index - len(
-            #             w.render_info.displayed_lines)
-            #     )
+            self.log_pane.log_container.scroll_up_one_page()
 
         @key_bindings.add('pagedown')
-        def _pagedown(event: KeyPressEvent) -> None:
+        def _pagedown(_event: KeyPressEvent) -> None:
             """Scroll the logs down by one page."""
-            _LOG.debug(pretty(self) + ' ' + pretty(event))
-            # w = event.app.layout.current_window
-            # if w.render_info:
-            #     self._selected_index = min(
-            #         len(self.values) - 1,
-            #         self._selected_index + len(w.render_info.displayed_lines),
-            #     )
+            self.log_pane.log_container.scroll_down_one_page()
 
         super().__init__(*args, key_bindings=key_bindings, **kwargs)
 
     def mouse_handler(self, mouse_event: MouseEvent):
         """Mouse handler for this control."""
         mouse_position = mouse_event.position
-        _LOG.debug(mouse_position)
-        # Already in focus
-        if get_app().layout.current_control == self:
-            return NotImplemented
-        # Focus buffer when clicked.
+
+        # Check for pane focus first.
+        # If not in focus, change forus to the log pane and do nothing else.
+        if not has_focus(self)():
+            if mouse_event.event_type == MouseEventType.MOUSE_UP:
+                # Focus buffer when clicked.
+                get_app().layout.focus(self)
+                # Mouse event handled, return None.
+                return None
+
         if mouse_event.event_type == MouseEventType.MOUSE_UP:
-            get_app().layout.current_control = self
-        else:
-            return NotImplemented
-        return None
+            # Scroll to the line clicked.
+            self.log_pane.log_container.scroll_to_position(mouse_position)
+            # Mouse event handled, return None.
+            return None
+
+        if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+            self.log_pane.log_container.scroll_down()
+            # Mouse event handled, return None.
+            return None
+
+        if mouse_event.event_type == MouseEventType.SCROLL_UP:
+            self.log_pane.log_container.scroll_up()
+            # Mouse event handled, return None.
+            return None
+
+        # Mouse event not handled, return NotImplemented.
+        return NotImplemented
+
+
+class LogLineHSplit(HSplit):
+    def __init__(self, log_pane, *args, **kwargs):
+        self.log_pane = log_pane
+        super().__init__(*args, **kwargs)
+
+    def write_to_screen(
+        self,
+        screen,
+        mouse_handlers,
+        write_position,
+        parent_style: str,
+        erase_bg: bool,
+        z_index: Optional[int],
+    ) -> None:
+        # Save current render pass width and height
+        self.log_pane.update_log_pane_size(write_position.width,
+                                           write_position.height)
+
+        super().write_to_screen(screen, mouse_handlers, write_position,
+                                parent_style, erase_bg, z_index)
 
 
 @dataclass
@@ -186,42 +306,130 @@
     application: Any
     log_container: LogContainer = field(default_factory=LogContainer)
     show_bottom_toolbar = True
+    show_line_info = True
     wrap_lines = True
+    height = Dimension(weight=50)
+    width = Dimension(weight=50)
 
     def __post_init__(self):
         """LogPane post initialize function, called after __init__()."""
+
+        self.current_log_pane_width = 0
+        self.current_log_pane_height = 0
+        self.last_log_pane_width = 0
+        self.last_log_pane_height = 0
+
+        self._last_log_index = None
+        self.last_log_content_height = 0
+
+        # Set the passed in log_container instance's log_pane reference to this.
+        self.log_container.set_log_pane(self)
+
+        # Create the bottom toolbar for the whole log pane.
         self.bottom_toolbar = LogPaneBottomToolbarBar(self)
-        # Log Content control instnce
+        self._last_selected_log_index = 0
+        self._max_log_item_count = 10
+
         self.log_content_control = LogContentControl(
             self,  # parent LogPane
             # FormattedTextControl args:
             self.log_container.draw,
-            show_cursor=True,
+            # Hide the cursor, use cursorline=True in self.log_display_window to
+            # indicate currently selected line.
+            show_cursor=False,
             focusable=True,
+            get_cursor_position=self.log_content_control_get_cursor_position,
         )
-        self.log_container.set_log_content_control(self.log_content_control)
 
         self.log_display_window = Window(
             content=self.log_content_control,
-            scroll_offsets=ScrollOffsets(bottom=1),
-            get_line_prefix=LogContentControl.
-            indent_wrapped_pw_log_format_line,
+            # TODO: ScrollOffsets here causes jumpiness when lines are wrapped.
+            scroll_offsets=ScrollOffsets(top=0, bottom=0),
+            allow_scroll_beyond_bottom=True,
+            get_line_prefix=partial(
+                LogContentControl.indent_wrapped_pw_log_format_line, self),
             wrap_lines=Condition(lambda: self.wrap_lines),
-            # Make this window as tall as possible
-            height=Dimension(preferred=10**10),
+            cursorline=False,
+
+            # Don't make the window taller to fill the parent split container.
+            # Window should match the height of the log line content. This will
+            # also allow the parent HSplit to justify the content to the bottom
+            dont_extend_height=True,
+            # Window width should be extended to make backround highlighting
+            # extend to the end of the container. Otherwise backround colors
+            # will only appear until the end of the log line.
+            dont_extend_width=False,
         )
 
-        self.container = HSplit([
-            self.log_display_window,
-            self.bottom_toolbar,
-        ])
+        # Root level container
+        self.container = FloatContainer(
+            LogLineHSplit(
+                self,  # LogPane reference
+                [
+                    self.log_display_window,
+                    self.bottom_toolbar,
+                ],
+                align=VerticalAlign.BOTTOM,
+                height=self.height,
+                width=self.width,
+            ),
+            floats=[
+                Float(top=0,
+                      right=0,
+                      height=1,
+                      content=LogPaneLineInfoBar(self)),
+            ])
+
+    def update_log_pane_size(self, width, height):
+        if width:
+            self.last_log_pane_width = self.current_log_pane_width
+            self.current_log_pane_width = width
+        if height:
+            height -= 1  # Subtract 1 for the LogPaneBottomToolbarBar
+            self.last_log_pane_height = self.current_log_pane_height
+            self.current_log_pane_height = height
+
+    def after_render_hook(self):
+        self.reset_log_content_height()
+
+    def reset_log_content_height(self):
+        """Reset log line pane content height."""
+        self.last_log_content_height = 0
+
+    def is_in_focus(self):
+        """Return if this LogPane is in focus or not."""
+        console_app = self.application
+        if not hasattr(console_app, 'application'):
+            return False
+        current_control = console_app.application.layout.current_window.content
+        return current_control == self.log_content_control
+
+    def redraw_ui(self):
+        """Trigger a prompt_toolkit UI redraw."""
+        self.application.redraw_ui()
+
+    def log_content_control_get_cursor_position(self):
+        return self.log_container.get_cursor_position()
 
     def toggle_wrap_lines(self):
         """Toggle line wraping/truncation."""
         self.wrap_lines = not self.wrap_lines
+        self.redraw_ui()
+
+    def toggle_follow(self):
+        """Toggle following log lines."""
+        self.log_container.toggle_follow()
+        self.redraw_ui()
+
+    def clear_history(self):
+        """Erase stored log lines."""
+        self.log_container.clear_logs()
+        self.redraw_ui()
 
     def get_all_key_bindings(self) -> List:
         """Return all keybinds for this plugin."""
+        # return [LogLineHSplit(self, []).get_key_bindings()]
+        # return [LogContentControl(self).get_key_bindings()]
         return [self.log_content_control.get_key_bindings()]
 
     def __pt_container__(self):
diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py
index e1eef0f..0ce6270 100644
--- a/pw_console/py/pw_console/pw_ptpython_repl.py
+++ b/pw_console/py/pw_console/pw_ptpython_repl.py
@@ -15,14 +15,16 @@
 
 import asyncio
 import logging
+import io
+import sys
+from functools import partial
 from pathlib import Path
 
 from prompt_toolkit.buffer import Buffer
-from prompt_toolkit.document import Document
-# TODO: Use patch_stdout for running user repl code.
-# from prompt_toolkit.patch_stdout import patch_stdout
 from ptpython import repl  # type: ignore
 
+from pw_console.utils import remove_formatting
+
 _LOG = logging.getLogger(__package__)
 
 
@@ -32,6 +34,7 @@
         #self.ptpython_layout.show_status_bar = False
         #self.ptpython_layout.show_exit_confirmation = False
         super().__init__(*args,
+                         create_app=False,
                          history_filename=(Path.home() /
                                            '.pw_console_history').as_posix(),
                          color_depth='256 colors',
@@ -43,6 +46,7 @@
         self.show_exit_confirmation = False
         self.complete_private_attributes = False
         self.repl_pane = None
+        self._last_result = None
 
     def __pt_container__(self):
         return self.ptpython_layout.root_container
@@ -50,55 +54,103 @@
     def set_repl_pane(self, repl_pane):
         self.repl_pane = repl_pane
 
-    def _append_result_to_output(self, formatted_text):
-        # Throw away style info.
-        unformatted_result = ''.join(
-            list(formatted_tuple[1] for formatted_tuple in formatted_text))  # pylint: disable=not-an-iterable
+    def _save_result(self, formatted_text):
+        """Save the last repl execution result."""
+        # TODO: This isn't thread safe.
+        unformatted_result = remove_formatting(formatted_text)
+        # Save last result
+        self._last_result = unformatted_result
 
-        # Get old buffer contents and append the result
-        new_text = self.repl_pane.output_field.buffer.text
-        new_text += unformatted_result
+    def clear_last_result(self):
+        """Erase the last repl execution result."""
+        self._last_result = None
 
-        # Set the output buffer to new_text and move the cursor to the end
-        self.repl_pane.output_field.buffer.document = Document(
-            text=new_text, cursor_position=len(new_text))
+    def _update_output_buffer(self):
+        self.repl_pane.update_output_buffer()
 
     def show_result(self, result):
         formatted_result = self._format_result_output(result)
-        self._append_result_to_output(formatted_result)
+        self._save_result(formatted_result)
 
     def _handle_exception(self, e: BaseException) -> None:
         formatted_result = self._format_exception_output(e)
-        self._append_result_to_output(formatted_result)
+        self._save_result(formatted_result.__pt_formatted_text__())
 
-    def user_code_complete_callback(self, unused_future):
+    def user_code_complete_callback(self, input_text, future):
         """Callback to run after user repl code is finished."""
-        # TODO: Maybe show result as a log line?
+        # If there was an exception it will be saved in self._last_result
+        result = self._last_result
+        # _last_result consumed, erase for the next run.
+        self.clear_last_result()
+
+        stdout_contents = None
+        stderr_contents = None
+        if future.result():
+            future_result = future.result()
+            stdout_contents = future_result['stdout']
+            stderr_contents = future_result['stderr']
+            result_value = future_result['result']
+
+            if result_value is not None:
+                formatted_result = self._format_result_output(result_value)
+                result = remove_formatting(formatted_result)
+
+        # Job is finished, append the last result.
+        self.repl_pane.append_result_to_executed_code(input_text, future,
+                                                      result, stdout_contents,
+                                                      stderr_contents)
+
+        # Rebuild output buffer.
+        self._update_output_buffer()
+
         # Trigger a prompt_toolkit application redraw.
         self.repl_pane.application.application.invalidate()
 
+    async def _run_user_code(self, text):
+        """Run user code and capture stdout+err."""
+
+        original_stdout = sys.stdout
+        original_stderr = sys.stderr
+
+        temp_out = io.StringIO()
+        temp_err = io.StringIO()
+
+        sys.stdout = temp_out
+        sys.stderr = temp_err
+
+        try:
+            result = await self.run_and_show_expression_async(text)
+        finally:
+            sys.stdout = original_stdout
+            sys.stderr = original_stderr
+
+        stdout_contents = temp_out.getvalue()
+        stderr_contents = temp_err.getvalue()
+
+        return {
+            'stdout': stdout_contents,
+            'stderr': stderr_contents,
+            'result': result
+        }
+
     def _accept_handler(self, buff: Buffer) -> bool:
         # Do nothing if no text is entered.
         if len(buff.text) == 0:
             return False
 
-        # Get old buffer contents
-        new_text = self.repl_pane.output_field.buffer.text
-        # Append the prompt and input
-        new_text += '\n\n>>> ' + buff.text + '\n'
-        # Set the output buffer to new_text and move the cursor to the end
-        self.repl_pane.output_field.buffer.document = Document(
-            text=new_text, cursor_position=len(new_text))
-
-        # TODO: patch_stdout doesn't seem to work here.
-        # with patch_stdout():
-
         # Execute the repl code in the user_code thread loop
         future = asyncio.run_coroutine_threadsafe(
-            self.run_and_show_expression_async(buff.text),
+            self._run_user_code(buff.text),
             self.repl_pane.application.user_code_loop)
         # Run user_code_complete_callback() when done.
-        future.add_done_callback(self.user_code_complete_callback)
+        done_callback = partial(self.user_code_complete_callback, buff.text)
+        future.add_done_callback(done_callback)
+
+        # Save the input text and future.
+        self.repl_pane.append_executed_code(buff.text, future)
+
+        # Rebuild output buffer.
+        self._update_output_buffer()
 
         # TODO: Return True if exception is found.
         # Don't keep input for now. Return True to keep input text.
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index fedf96c..2aaa201 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -14,7 +14,9 @@
 """ReplPane class."""
 
 import inspect
+import concurrent
 import logging
+from dataclasses import dataclass
 from functools import partial
 from typing import (
     Any,
@@ -25,16 +27,20 @@
 )
 
 # from IPython.lib.pretty import pretty  # type: ignore
+from jinja2 import Template
 from prompt_toolkit.filters import (
     Condition,
     has_focus,
 )
+from prompt_toolkit.document import Document
 from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 from prompt_toolkit.layout.dimension import AnyDimension
 from prompt_toolkit.widgets import TextArea
 from prompt_toolkit.layout import (
     ConditionalContainer,
     Dimension,
+    Float,
+    FloatContainer,
     FormattedTextControl,
     HSplit,
     VSplit,
@@ -52,29 +58,43 @@
 _GetNamespace = Callable[[], _Namespace]
 
 
-class ReplPaneBottomToolbarBar(ConditionalContainer):
-    """Repl pane bottom toolbar."""
-    @staticmethod
-    def mouse_handler(repl_pane, mouse_event: MouseEvent):
-        """Focus this pane on click."""
+def mouse_focus_handler(repl_pane, mouse_event: MouseEvent):
+    """Focus the repl_pane on click."""
+    if not has_focus(repl_pane)():
         if mouse_event.event_type == MouseEventType.MOUSE_UP:
             repl_pane.application.application.layout.focus(repl_pane)
             return None
-        return NotImplemented
+        # If any actions should happen if in focus NotImplemented should be
+        # returned here.
+        # return NotImplemented
+    return NotImplemented
 
+
+class FocusOnClickFloatContainer(ConditionalContainer):
+    """Empty container shown if the repl_pane is not in focus.
+
+    Container is hidden if already in focus.
+    """
+    def __init__(self, repl_pane):
+        super().__init__(Window(
+            FormattedTextControl([('', ' ',
+                                   partial(mouse_focus_handler,
+                                           repl_pane))]), ),
+                         filter=Condition(lambda: not has_focus(repl_pane)()))
+
+
+class ReplPaneBottomToolbarBar(ConditionalContainer):
+    """Repl pane bottom toolbar."""
     @staticmethod
     def get_center_text_tokens(repl_pane):
         """Return if the ReplPane is in focus or not."""
-        if has_focus(repl_pane.__pt_container__())():
+        if has_focus(repl_pane)():
             return [
-                ("", " [FOCUSED] ",
-                 partial(ReplPaneBottomToolbarBar.mouse_handler, repl_pane)),
+                ("", " [FOCUSED] ", partial(mouse_focus_handler, repl_pane)),
             ]
         return [
-            # TODO: Clicking on the actual logs status bar doesn't focus; only
-            # when clicking on the log content itself. Fix this.
             ('class:keyhelp', ' [click to focus] ',
-             partial(ReplPaneBottomToolbarBar.mouse_handler, repl_pane)),
+             partial(mouse_focus_handler, repl_pane)),
         ]
 
     def __init__(self, repl_pane):
@@ -83,8 +103,7 @@
                 [
                     Window(content=FormattedTextControl(
                         [('class:logo', ' Python Input ',
-                          partial(ReplPaneBottomToolbarBar.mouse_handler,
-                                  repl_pane))]),
+                          partial(mouse_focus_handler, repl_pane))]),
                            align=WindowAlign.LEFT,
                            dont_extend_width=True),
                     Window(content=FormattedTextControl(
@@ -93,12 +112,11 @@
                             repl_pane)),
                            align=WindowAlign.LEFT,
                            dont_extend_width=False),
-                    Window(
-                        content=FormattedTextControl(
-                            [('class:bottom_toolbar_colored_text',
-                              ' [Enter]: run code ')]),
-                        align=WindowAlign.RIGHT,
-                        dont_extend_width=True),
+                    Window(content=FormattedTextControl(
+                        [('class:bottom_toolbar_colored_text',
+                          ' [Enter]: run code ')]),
+                           align=WindowAlign.RIGHT,
+                           dont_extend_width=True),
                 ],
                 height=1,
                 style='class:bottom_toolbar',
@@ -106,6 +124,20 @@
             filter=Condition(lambda: repl_pane.show_bottom_toolbar))
 
 
+@dataclass
+class UserCodeExecution:
+    """Class to hold a single user repl execution."""
+    input: str
+    future: concurrent.futures.Future
+    output: str
+    stdout: str
+    stderr: str
+
+    @property
+    def is_running(self):
+        return not self.future.done()
+
+
 class ReplPane:
     """Pane for reading Python input."""
 
@@ -115,10 +147,16 @@
             application: Any,
             python_repl: PwPtPythonRepl,
             # TODO: Make the height of input+output windows match the log pane
-            # height. Use 5 for now.
-            output_height: Optional[AnyDimension] = Dimension(preferred=15),
-            unused_input_height: Optional[AnyDimension] = None) -> None:
+            # height. Use 15 for now.
+            output_height: Optional[AnyDimension] = Dimension(preferred=5),
+            unused_input_height: Optional[AnyDimension] = None,
+            height: Optional[AnyDimension] = Dimension(weight=50),
+            width: Optional[AnyDimension] = Dimension(weight=50),
+    ) -> None:
+        self.height = height
+        self.width = width
 
+        self.executed_code: List = []
         self.application = application
         self.show_top_toolbar = True
         self.show_bottom_toolbar = True
@@ -126,18 +164,6 @@
         self.pw_ptpython_repl = python_repl
         self.last_error_output = ""
 
-        help_text = inspect.cleandoc("""
-        Type any expression (e.g. "4 + 4") followed by enter to execute.
-        """)
-
-        if output_height:
-            rows = 0
-            if type(output_height).__name__ == 'int':
-                rows = output_height  # type: ignore
-            else:
-                rows = output_height.min  # type: ignore
-            help_text = ('\n' * rows) + help_text
-
         self.output_field = TextArea(
             style='class:output-field',
             height=output_height,
@@ -150,16 +176,33 @@
 
         self.bottom_toolbar = ReplPaneBottomToolbarBar(self)
 
-        self.container = HSplit([
-            self.output_field,
-            # Dashed line separator
-            Window(content=FormattedTextControl([('class:logo',
-                                                  ' Python Results ')]),
-                   height=1,
-                   style='class:menu-bar'),
-            self.pw_ptpython_repl,
-            self.bottom_toolbar,
-        ])
+        self.container = FloatContainer(
+            HSplit(
+                [
+                    self.output_field,
+                    # Dashed line separator
+                    Window(content=FormattedTextControl(
+                        [('class:logo', ' Python Results ')]),
+                           height=1,
+                           style='class:menu-bar'),
+                    self.pw_ptpython_repl,
+                    self.bottom_toolbar,
+                ],
+                height=self.height,
+                width=self.width,
+            ),
+            floats=[
+                # Transparent float container that will focus on the repl_pane
+                # when clicked. It is hidden if already in focus.
+                Float(
+                    FocusOnClickFloatContainer(self),
+                    transparent=True,
+                    right=0,
+                    left=0,
+                    top=0,
+                    bottom=1,
+                ),
+            ])
 
     def __pt_container__(self):
         """Return the prompt_toolkit container for this ReplPane."""
@@ -170,5 +213,95 @@
         """Return all keybinds for this plugin."""
         return []
 
-    def append_to_output(self, formatted_text):
-        pass
+    def ctrl_c(self):
+        """Ctrl-C keybinding behavior."""
+        # If there is text in the input buffer, clear it.
+        if self.pw_ptpython_repl.default_buffer.text:
+            self.clear_input_buffer()
+        else:
+            self.interrupt_last_code_execution()
+
+    def clear_input_buffer(self):
+        self.pw_ptpython_repl.default_buffer.reset()
+
+    def interrupt_last_code_execution(self):
+        code = self._get_currently_running_code()
+        if code:
+            code.future.cancel()
+            code.output = 'Canceled'
+        self.pw_ptpython_repl.clear_last_result()
+        self.update_output_buffer()
+
+    def _get_currently_running_code(self):
+        for code in self.executed_code:
+            if not code.future.done():
+                return code
+        return None
+
+    def _get_executed_code(self, future):
+        for code in self.executed_code:
+            if code.future == future:
+                return code
+        return None
+
+    def _log_executed_code(self, code, prefix=''):
+        text = self.get_output_buffer_text([code], show_index=False)
+        _LOG.info('[PYTHON] %s\n%s', prefix, text)
+
+    def append_executed_code(self, text, future):
+        user_code = UserCodeExecution(input=text,
+                                      future=future,
+                                      output=None,
+                                      stdout=None,
+                                      stderr=None)
+        self.executed_code.append(user_code)
+        self._log_executed_code(user_code, prefix='START')
+
+    def append_result_to_executed_code(self,
+                                       _input_text,
+                                       future,
+                                       result_text,
+                                       stdout_text='',
+                                       stderr_text=''):
+        code = self._get_executed_code(future)
+        if code:
+            code.output = result_text
+            code.stdout = stdout_text
+            code.stderr = stderr_text
+        self._log_executed_code(code, prefix='FINISH')
+        self.update_output_buffer()
+
+    def get_output_buffer_text(self, code_items=None, show_index=True):
+        executed_code = code_items or self.executed_code
+        template_text = inspect.cleandoc("""
+            {% for code in code_items %}
+            {% set index = loop.index if show_index else '' %}
+            {% set prompt_width = 7 + index|string|length %}
+            In [{{index}}]: {{ code.input|indent(width=prompt_width) }}
+            {% if code.is_running %}
+            Running...
+            {% else %}
+            {% if code.stdout -%}
+              {{ code.stdout }}
+            {%- endif %}
+            {% if code.stderr -%}
+              {{ code.stderr }}
+            {%- endif %}
+            {% if code.output %}
+            Out[{{index}}]: {{ code.output|indent(width=prompt_width) }}
+            {% endif %}
+            {% endif %}
+
+            {% endfor %}
+            """)
+        template = Template(template_text,
+                            trim_blocks=True,
+                            lstrip_blocks=True)
+        return template.render(code_items=executed_code,
+                               show_index=show_index).strip()
+
+    def update_output_buffer(self):
+        text = self.get_output_buffer_text()
+        self.output_field.buffer.document = Document(text=text,
+                                                     cursor_position=len(text))
+        self.application.redraw_ui()
diff --git a/pw_console/py/pw_console/utils.py b/pw_console/py/pw_console/utils.py
index af53661..54f3f2b 100644
--- a/pw_console/py/pw_console/utils.py
+++ b/pw_console/py/pw_console/utils.py
@@ -21,3 +21,38 @@
             break
         size /= 1024.0
     return f'{size:.{decimal_places}f} {unit}'
+
+
+def remove_formatting(formatted_text):
+    """Throw away style info from formatted text tuples."""
+    return ''.join([formatted_tuple[1] for formatted_tuple in formatted_text])  # pylint: disable=not-an-iterable
+
+
+def formatted_text_splitlines(formatted_text_tuples):
+    lines = [[]]
+    for index, fragment in enumerate(formatted_text_tuples):
+        # if len(lines[-1]) > 0 and lines[-1][-1][0] == fragment[0]:
+        #     lines[-1][-1] = (fragment[0], lines[-1][-1][1] + fragment[1])
+        # else:
+        lines[-1].append(fragment)
+        if fragment[1] == '\n' and index < len(formatted_text_tuples) - 1:
+            lines.append([])
+    return lines
+
+
+def get_line_height(text_width, screen_width, prefix_width):
+    if text_width == 0:
+        return 0
+    if text_width < screen_width:
+        return 1
+
+    total_height = 1
+    remaining_width = text_width - screen_width
+
+    while (remaining_width + prefix_width) > screen_width:
+        remaining_width += prefix_width
+        remaining_width -= screen_width
+        total_height += 1
+
+    # Add one for the last line that is < screen_width
+    return total_height + 1
diff --git a/pw_console/py/setup.py b/pw_console/py/setup.py
index afe53c8..c383d1a 100644
--- a/pw_console/py/setup.py
+++ b/pw_console/py/setup.py
@@ -30,14 +30,14 @@
         ]
     },
     install_requires=[
-        "ipdb",
-        "ipython",
-        "jinja2",
-        "prompt_toolkit",
-        'ptpython @ '
-        'git+git://github.com/AnthonyDiGirolamo/ptpython.git@ptpython-library',
-        "pw_cli",
-        "pw_tokenizer",
-        "pygments",
+        'ipdb',
+        'ipython',
+        'jinja2',
+        'prompt_toolkit',
+        # inclusive-language: ignore
+        'ptpython @ git+git://github.com/prompt-toolkit/ptpython.git@master',
+        'pw_cli',
+        'pw_tokenizer',
+        'pygments',
     ],
 )