Repository: python-cmd2/cmd2 (python)

Issue:
What should show in cmd2 history?
@kotfu @tleonhardt 
This question pertains to cmd2's history and not readline's.

Currently we put what the user typed in history. (`statement.raw`) Is there any reason why can't put what was run instead? (`statement.command_and_args`)

That would make it clearer when things like shortcuts and aliases are entered. You will have a record of what actually occurred.

Pull request:
Improvements to history command
# Summary
This PR contains numerous changes to the **history** command made by a mix of @kmvanbrunt, @kotfu, and @tleonhardt over the last few months.  It feels like these changes are either done or almost done and it was high time to create a PR and hash out any last remaining details.

# Details
## Presentation format
The presentation format has been improved and simplified.

Current:
```sh
(Cmd) history
    1  help
    2  help history
```

Before:
```sh
(Cmd) history
-------------------------[1]
help
-------------------------[2]
help history
```

## New flags
* **-x**, **--expanded**
    * output expanded commands instead of entered command (expands aliases, macros, and shortcuts)
* **-v**, **--verbose**
    * display history and include expanded commands if they differ from the typed command

## Refactoring
* ``HistoryItem`` and ``History`` classes were moved from **cmd2.py** to a new **history.py**
* ``HIstoryItem`` now retains the full ``Statement`` instance
* Added ``expanded_command_line`` property to ``HistoryItem``

## Improvements
* Improved multiline command support for things like outputting history commands to a script file

# TODO
- [x] Final merge from *master* to pick up changes from other recent PRs
- [x] Updated CHANGELOG
- [x] Update Sphinx docs and/or README as we deem necessary
- [x] Consider adding unit tests


This closes #545 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71fb35ec..dd9966cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 ## 0.9.11 (TBD, 2019)
+* Bug Fixes
+    * Fixed bug in how **history** command deals with multiline commands when output to a script
 * Enhancements
+    * Improvements to the **history** command
+        * Simplified the display format and made it more similar to **bash**
+        * Added **-x**, **--expanded** flag
+            * output expanded commands instead of entered command (expands aliases, macros, and shortcuts)
+        * Added **-v**, **--verbose** flag
+            * display history and include expanded commands if they differ from the typed command
     * Added ``matches_sort_key`` to override the default way tab completion matches are sorted
 * Potentially breaking changes
     * Made ``cmd2_app`` a positional and required argument of ``AutoCompleter`` since certain functionality now
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index df495a4a..5912bee3 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -49,6 +49,7 @@ from . import utils
 from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES
 from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
 from .parsing import StatementParser, Statement, Macro, MacroArg
+from .history import History, HistoryItem
 
 # Set up readline
 from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt
@@ -290,30 +291,6 @@ class EmptyStatement(Exception):
     pass
 
 
-class HistoryItem(str):
-    """Class used to represent an item in the History list.
-
-    Thin wrapper around str class which adds a custom format for printing. It
-    also keeps track of its index in the list as well as a lowercase
-    representation of itself for convenience/efficiency.
-
-    """
-    listformat = '-------------------------[{}]\n{}\n'
-
-    # noinspection PyUnusedLocal
-    def __init__(self, instr: str) -> None:
-        str.__init__(self)
-        self.lowercase = self.lower()
-        self.idx = None
-
-    def pr(self) -> str:
-        """Represent a HistoryItem in a pretty fashion suitable for printing.
-
-        :return: pretty print string version of a HistoryItem
-        """
-        return self.listformat.format(self.idx, str(self).rstrip())
-
-
 class Cmd(cmd.Cmd):
     """An easy but powerful framework for writing line-oriented command interpreters.
 
@@ -325,7 +302,7 @@ class Cmd(cmd.Cmd):
     # Attributes used to configure the StatementParser, best not to change these at runtime
     multiline_commands = []
     shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'}
-    terminators = [';']
+    terminators = [constants.MULTILINE_TERMINATOR]
 
     # Attributes which are NOT dynamically settable at runtime
     allow_cli_args = True       # Should arguments passed on the command-line be processed as commands?
@@ -2007,7 +1984,7 @@ class Cmd(cmd.Cmd):
             if func:
                 # Since we have a valid command store it in the history
                 if statement.command not in self.exclude_from_history:
-                    self.history.append(statement.raw)
+                    self.history.append(statement)
 
                 stop = func(statement)
 
@@ -2070,7 +2047,7 @@ class Cmd(cmd.Cmd):
         """
         if self.default_to_shell:
             if 'shell' not in self.exclude_from_history:
-                self.history.append(statement.raw)
+                self.history.append(statement)
 
             return self.do_shell(statement.command_and_args)
         else:
@@ -3188,18 +3165,27 @@ class Cmd(cmd.Cmd):
                 load_ipy(bridge)
 
     history_parser = ACArgumentParser()
-    history_parser_group = history_parser.add_mutually_exclusive_group()
-    history_parser_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
-    history_parser_group.add_argument('-e', '--edit', action='store_true',
+    history_action_group = history_parser.add_mutually_exclusive_group()
+    history_action_group.add_argument('-r', '--run', action='store_true', help='run selected history items')
+    history_action_group.add_argument('-e', '--edit', action='store_true',
                                       help='edit and then run selected history items')
-    history_parser_group.add_argument('-s', '--script', action='store_true', help='output commands in script format')
-    setattr(history_parser_group.add_argument('-o', '--output-file', metavar='FILE',
-                                              help='output commands to a script file'),
+    setattr(history_action_group.add_argument('-o', '--output-file', metavar='FILE',
+                                              help='output commands to a script file, implies -s'),
             ACTION_ARG_CHOICES, ('path_complete',))
-    setattr(history_parser_group.add_argument('-t', '--transcript',
-                                              help='output commands and results to a transcript file'),
+    setattr(history_action_group.add_argument('-t', '--transcript',
+                                              help='output commands and results to a transcript file, implies -s'),
             ACTION_ARG_CHOICES, ('path_complete',))
-    history_parser_group.add_argument('-c', '--clear', action="store_true", help='clear all history')
+    history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history')
+
+    history_format_group = history_parser.add_argument_group(title='formatting')
+    history_script_help = 'output commands in script format, i.e. without command numbers'
+    history_format_group.add_argument('-s', '--script', action='store_true', help=history_script_help)
+    history_expand_help = 'output expanded commands instead of entered command'
+    history_format_group.add_argument('-x', '--expanded', action='store_true', help=history_expand_help)
+    history_format_group.add_argument('-v', '--verbose', action='store_true',
+                                      help='display history and include expanded commands if they'
+                                           ' differ from the typed command')
+
     history_arg_help = ("empty               all history items\n"
                         "a                   one history item by number\n"
                         "a..b, a:b, a:, ..b  items by indices (inclusive)\n"
@@ -3211,6 +3197,19 @@ class Cmd(cmd.Cmd):
     def do_history(self, args: argparse.Namespace) -> None:
         """View, run, edit, save, or clear previously entered commands"""
 
+        # -v must be used alone with no other options
+        if args.verbose:
+            if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script:
+                self.poutput("-v can not be used with any other options")
+                self.poutput(self.history_parser.format_usage())
+                return
+
+        # -s and -x can only be used if none of these options are present: [-c -r -e -o -t]
+        if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript):
+            self.poutput("-s and -x can not be used with -c, -r, -e, -o, or -t")
+            self.poutput(self.history_parser.format_usage())
+            return
+
         if args.clear:
             # Clear command and readline history
             self.history.clear()
@@ -3257,7 +3256,10 @@ class Cmd(cmd.Cmd):
             fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
             with os.fdopen(fd, 'w') as fobj:
                 for command in history:
-                    fobj.write('{}\n'.format(command))
+                    if command.statement.multiline_command:
+                        fobj.write('{}\n'.format(command.expanded.rstrip()))
+                    else:
+                        fobj.write('{}\n'.format(command))
             try:
                 self.do_edit(fname)
                 self.do_load(fname)
@@ -3269,7 +3271,10 @@ class Cmd(cmd.Cmd):
             try:
                 with open(os.path.expanduser(args.output_file), 'w') as fobj:
                     for command in history:
-                        fobj.write('{}\n'.format(command))
+                        if command.statement.multiline_command:
+                            fobj.write('{}\n'.format(command.expanded.rstrip()))
+                        else:
+                            fobj.write('{}\n'.format(command))
                 plural = 's' if len(history) > 1 else ''
                 self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file))
             except Exception as e:
@@ -3279,10 +3284,7 @@ class Cmd(cmd.Cmd):
         else:
             # Display the history items retrieved
             for hi in history:
-                if args.script:
-                    self.poutput(hi)
-                else:
-                    self.poutput(hi.pr())
+                self.poutput(hi.pr(script=args.script, expanded=args.expanded, verbose=args.verbose))
 
     def _generate_transcript(self, history: List[HistoryItem], transcript_file: str) -> None:
         """Generate a transcript file from a given history of commands."""
@@ -3807,113 +3809,6 @@ class Cmd(cmd.Cmd):
         self._cmdfinalization_hooks.append(func)
 
 
-class History(list):
-    """ A list of HistoryItems that knows how to respond to user requests. """
-
-    # noinspection PyMethodMayBeStatic
-    def _zero_based_index(self, onebased: int) -> int:
-        """Convert a one-based index to a zero-based index."""
-        result = onebased
-        if result > 0:
-            result -= 1
-        return result
-
-    def _to_index(self, raw: str) -> Optional[int]:
-        if raw:
-            result = self._zero_based_index(int(raw))
-        else:
-            result = None
-        return result
-
-    spanpattern = re.compile(r'^\s*(?P<start>-?\d+)?\s*(?P<separator>:|(\.{2,}))?\s*(?P<end>-?\d+)?\s*$')
-
-    def span(self, raw: str) -> List[HistoryItem]:
-        """Parses the input string search for a span pattern and if if found, returns a slice from the History list.
-
-        :param raw: string potentially containing a span of the forms a..b, a:b, a:, ..b
-        :return: slice from the History list
-        """
-        if raw.lower() in ('*', '-', 'all'):
-            raw = ':'
-        results = self.spanpattern.search(raw)
-        if not results:
-            raise IndexError
-        if not results.group('separator'):
-            return [self[self._to_index(results.group('start'))]]
-        start = self._to_index(results.group('start')) or 0  # Ensure start is not None
-        end = self._to_index(results.group('end'))
-        reverse = False
-        if end is not None:
-            if end < start:
-                (start, end) = (end, start)
-                reverse = True
-            end += 1
-        result = self[start:end]
-        if reverse:
-            result.reverse()
-        return result
-
-    rangePattern = re.compile(r'^\s*(?P<start>[\d]+)?\s*-\s*(?P<end>[\d]+)?\s*$')
-
-    def append(self, new: str) -> None:
-        """Append a HistoryItem to end of the History list
-
-        :param new: command line to convert to HistoryItem and add to the end of the History list
-        """
-        new = HistoryItem(new)
-        list.append(self, new)
-        new.idx = len(self)
-
-    def get(self, getme: Optional[Union[int, str]] = None) -> List[HistoryItem]:
-        """Get an item or items from the History list using 1-based indexing.
-
-        :param getme: optional item(s) to get (either an integer index or string to search for)
-        :return: list of HistoryItems matching the retrieval criteria
-        """
-        if not getme:
-            return self
-        try:
-            getme = int(getme)
-            if getme < 0:
-                return self[:(-1 * getme)]
-            else:
-                return [self[getme - 1]]
-        except IndexError:
-            return []
-        except ValueError:
-            range_result = self.rangePattern.search(getme)
-            if range_result:
-                start = range_result.group('start') or None
-                end = range_result.group('start') or None
-                if start:
-                    start = int(start) - 1
-                if end:
-                    end = int(end)
-                return self[start:end]
-
-            getme = getme.strip()
-
-            if getme.startswith(r'/') and getme.endswith(r'/'):
-                finder = re.compile(getme[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE)
-
-                def isin(hi):
-                    """Listcomp filter function for doing a regular expression search of History.
-
-                    :param hi: HistoryItem
-                    :return: bool - True if search matches
-                    """
-                    return finder.search(hi)
-            else:
-                def isin(hi):
-                    """Listcomp filter function for doing a case-insensitive string search of History.
-
-                    :param hi: HistoryItem
-                    :return: bool - True if search matches
-                    """
-                    return utils.norm_fold(getme) in utils.norm_fold(hi)
-            return [itm for itm in self if isin(itm)]
-
-
 class Statekeeper(object):
     """Class used to save and restore state during load and py commands as well as when redirecting output or pipes."""
     def __init__(self, obj: Any, attribs: Iterable) -> None:
diff --git a/cmd2/constants.py b/cmd2/constants.py
index 3e35a542..854862c5 100644
--- a/cmd2/constants.py
+++ b/cmd2/constants.py
@@ -13,6 +13,7 @@ REDIRECTION_APPEND = '>>'
 REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT]
 REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND]
 COMMENT_CHAR = '#'
+MULTILINE_TERMINATOR = ';'
 
 # Regular expression to match ANSI escape codes
 ANSI_ESCAPE_RE = re.compile(r'\x1b[^m]*m')
diff --git a/cmd2/history.py b/cmd2/history.py
new file mode 100644
index 00000000..45de3478
--- /dev/null
+++ b/cmd2/history.py
@@ -0,0 +1,167 @@
+# coding=utf-8
+"""
+History management classes
+"""
+
+import re
+
+from typing import List, Optional, Union
+
+from . import utils
+from .parsing import Statement
+
+
+class HistoryItem(str):
+    """Class used to represent one command in the History list"""
+    listformat = ' {:>4}  {}\n'
+    ex_listformat = ' {:>4}x {}\n'
+
+    def __new__(cls, statement: Statement):
+        """Create a new instance of HistoryItem
+
+        We must override __new__ because we are subclassing `str` which is
+        immutable and takes a different number of arguments as Statement.
+        """
+        hi = super().__new__(cls, statement.raw)
+        hi.statement = statement
+        hi.idx = None
+        return hi
+
+    @property
+    def expanded(self) -> str:
+        """Return the command as run which includes shortcuts and aliases resolved plus any changes made in hooks"""
+        return self.statement.expanded_command_line
+
+    def pr(self, script=False, expanded=False, verbose=False) -> str:
+        """Represent a HistoryItem in a pretty fashion suitable for printing.
+
+        If you pass verbose=True, script and expanded will be ignored
+
+        :return: pretty print string version of a HistoryItem
+        """
+        if verbose:
+            ret_str = self.listformat.format(self.idx, str(self).rstrip())
+            if self != self.expanded:
+                ret_str += self.ex_listformat.format(self.idx, self.expanded.rstrip())
+        else:
+            if script:
+                # display without entry numbers
+                if expanded or self.statement.multiline_command:
+                    ret_str = self.expanded.rstrip()
+                else:
+                    ret_str = str(self)
+            else:
+                # display a numbered list
+                if expanded or self.statement.multiline_command:
+                    ret_str = self.listformat.format(self.idx, self.expanded.rstrip())
+                else:
+                    ret_str = self.listformat.format(self.idx, str(self).rstrip())
+        return ret_str
+
+
+class History(list):
+    """ A list of HistoryItems that knows how to respond to user requests. """
+
+    # noinspection PyMethodMayBeStatic
+    def _zero_based_index(self, onebased: int) -> int:
+        """Convert a one-based index to a zero-based index."""
+        result = onebased
+        if result > 0:
+            result -= 1
+        return result
+
+    def _to_index(self, raw: str) -> Optional[int]:
+        if raw:
+            result = self._zero_based_index(int(raw))
+        else:
+            result = None
+        return result
+
+    spanpattern = re.compile(r'^\s*(?P<start>-?\d+)?\s*(?P<separator>:|(\.{2,}))?\s*(?P<end>-?\d+)?\s*$')
+
+    def span(self, raw: str) -> List[HistoryItem]:
+        """Parses the input string search for a span pattern and if if found, returns a slice from the History list.
+
+        :param raw: string potentially containing a span of the forms a..b, a:b, a:, ..b
+        :return: slice from the History list
+        """
+        if raw.lower() in ('*', '-', 'all'):
+            raw = ':'
+        results = self.spanpattern.search(raw)
+        if not results:
+            raise IndexError
+        if not results.group('separator'):
+            return [self[self._to_index(results.group('start'))]]
+        start = self._to_index(results.group('start')) or 0  # Ensure start is not None
+        end = self._to_index(results.group('end'))
+        reverse = False
+        if end is not None:
+            if end < start:
+                (start, end) = (end, start)
+                reverse = True
+            end += 1
+        result = self[start:end]
+        if reverse:
+            result.reverse()
+        return result
+
+    rangePattern = re.compile(r'^\s*(?P<start>[\d]+)?\s*-\s*(?P<end>[\d]+)?\s*$')
+
+    def append(self, new: Statement) -> None:
+        """Append a HistoryItem to end of the History list
+
+        :param new: command line to convert to HistoryItem and add to the end of the History list
+        """
+        new = HistoryItem(new)
+        list.append(self, new)
+        new.idx = len(self)
+
+    def get(self, getme: Optional[Union[int, str]]=None) -> List[HistoryItem]:
+        """Get an item or items from the History list using 1-based indexing.
+
+        :param getme: optional item(s) to get (either an integer index or string to search for)
+        :return: list of HistoryItems matching the retrieval criteria
+        """
+        if not getme:
+            return self
+        try:
+            getme = int(getme)
+            if getme < 0:
+                return self[:(-1 * getme)]
+            else:
+                return [self[getme - 1]]
+        except IndexError:
+            return []
+        except ValueError:
+            range_result = self.rangePattern.search(getme)
+            if range_result:
+                start = range_result.group('start') or None
+                end = range_result.group('start') or None
+                if start:
+                    start = int(start) - 1
+                if end:
+                    end = int(end)
+                return self[start:end]
+
+            getme = getme.strip()
+
+            if getme.startswith(r'/') and getme.endswith(r'/'):
+                finder = re.compile(getme[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE)
+
+                def isin(hi):
+                    """Listcomp filter function for doing a regular expression search of History.
+
+                    :param hi: HistoryItem
+                    :return: bool - True if search matches
+                    """
+                    return finder.search(hi) or finder.search(hi.expanded)
+            else:
+                def isin(hi):
+                    """Listcomp filter function for doing a case-insensitive string search of History.
+
+                    :param hi: HistoryItem
+                    :return: bool - True if search matches
+                    """
+                    srch = utils.norm_fold(getme)
+                    return srch in utils.norm_fold(hi) or srch in utils.norm_fold(hi.expanded)
+            return [itm for itm in self if isin(itm)]
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index bd3a6900..5ec13fb7 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -187,6 +187,28 @@ class Statement(str):
             rtn = ''
         return rtn
 
+    @property
+    def expanded_command_line(self) -> str:
+        """Contains command_and_args plus any ending terminator, suffix, and redirection chars"""
+        rtn = self.command_and_args
+        if self.multiline_command:
+            rtn += constants.MULTILINE_TERMINATOR
+        elif self.terminator:
+            rtn += self.terminator
+
+        if self.suffix:
+            rtn += ' ' + self.suffix
+
+        if self.pipe_to:
+            rtn += ' | ' + ' '.join(self.pipe_to)
+
+        if self.output:
+            rtn += ' ' + self.output
+            if self.output_to:
+                rtn += ' ' + self.output_to
+
+        return rtn
+
     @property
     def argv(self) -> List[str]:
         """a list of arguments a la sys.argv.
@@ -220,7 +242,7 @@ class StatementParser:
     ):
         self.allow_redirection = allow_redirection
         if terminators is None:
-            self.terminators = [';']
+            self.terminators = [constants.MULTILINE_TERMINATOR]
         else:
             self.terminators = terminators
         if multiline_commands is None:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 223389b9..f41afb64 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -28,7 +28,7 @@ except ImportError:
 # Help text for base cmd2.Cmd application
 BASE_HELP = """Documented commands (type help <topic>):
 ========================================
-alias  help     load   py        quit  shell    
+alias  help     load   py        quit  shell
 edit   history  macro  pyscript  set   shortcuts
 """  # noqa: W291
 
@@ -50,7 +50,8 @@ shortcuts           List available shortcuts
 """
 
 # Help text for the history command
-HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg]
+HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT | -c] [-s] [-x] [-v]
+               [arg]
 
 View, run, edit, save, or clear previously entered commands
 
@@ -65,12 +66,17 @@ optional arguments:
   -h, --help            show this help message and exit
   -r, --run             run selected history items
   -e, --edit            edit and then run selected history items
-  -s, --script          output commands in script format
   -o, --output-file FILE
-                        output commands to a script file
+                        output commands to a script file, implies -s
   -t, --transcript TRANSCRIPT
-                        output commands and results to a transcript file
+                        output commands and results to a transcript file, implies -s
   -c, --clear           clear all history
+
+formatting:
+  -s, --script          output commands in script format, i.e. without command numbers
+  -x, --expanded        output expanded commands instead of entered command
+  -v, --verbose         display history and include expanded commands if they differ from the typed command
+
 """
 
 # Output from the shortcuts command with default built-in shortcuts
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index af1d41c9..d43f7786 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -55,10 +55,6 @@ def test_base_help_verbose(base_app):
     out = run_cmd(base_app, 'help --verbose')
     assert out == expected
 
-def test_base_help_history(base_app):
-    out = run_cmd(base_app, 'help history')
-    assert out == normalize(HELP_HISTORY)
-
 def test_base_argparse_help(base_app, capsys):
     # Verify that "set -h" gives the same output as "help set" and that it starts in a way that makes sense
     run_cmd(base_app, 'set -h')
@@ -301,206 +297,6 @@ def test_base_error(base_app):
     assert "is not a recognized command" in out[0]
 
 
-@pytest.fixture
-def hist():
-    from cmd2.cmd2 import History, HistoryItem
-    h = History([HistoryItem('first'), HistoryItem('second'), HistoryItem('third'), HistoryItem('fourth')])
-    return h
-
-def test_history_span(hist):
-    h = hist
-    assert h == ['first', 'second', 'third', 'fourth']
-    assert h.span('-2..') == ['third', 'fourth']
-    assert h.span('2..3') == ['second', 'third']    # Inclusive of end
-    assert h.span('3') == ['third']
-    assert h.span(':') == h
-    assert h.span('2..') == ['second', 'third', 'fourth']
-    assert h.span('-1') == ['fourth']
-    assert h.span('-2..-3') == ['third', 'second']
-    assert h.span('*') == h
-
-def test_history_get(hist):
-    h = hist
-    assert h == ['first', 'second', 'third', 'fourth']
-    assert h.get('') == h
-    assert h.get('-2') == h[:-2]
-    assert h.get('5') == []
-    assert h.get('2-3') == ['second']           # Exclusive of end
-    assert h.get('ir') == ['first', 'third']    # Normal string search for all elements containing "ir"
-    assert h.get('/i.*d/') == ['third']         # Regex string search "i", then anything, then "d"
-
-def test_base_history(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    out = run_cmd(base_app, 'history')
-    expected = normalize("""
--------------------------[1]
-help
--------------------------[2]
-shortcuts
-""")
-    assert out == expected
-
-    out = run_cmd(base_app, 'history he')
-    expected = normalize("""
--------------------------[1]
-help
-""")
-    assert out == expected
-
-    out = run_cmd(base_app, 'history sh')
-    expected = normalize("""
--------------------------[2]
-shortcuts
-""")
-    assert out == expected
-
-def test_history_script_format(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    out = run_cmd(base_app, 'history -s')
-    expected = normalize("""
-help
-shortcuts
-""")
-    assert out == expected
-
-def test_history_with_string_argument(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    run_cmd(base_app, 'help history')
-    out = run_cmd(base_app, 'history help')
-    expected = normalize("""
--------------------------[1]
-help
--------------------------[3]
-help history
-""")
-    assert out == expected
-
-
-def test_history_with_integer_argument(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    out = run_cmd(base_app, 'history 1')
-    expected = normalize("""
--------------------------[1]
-help
-""")
-    assert out == expected
-
-
-def test_history_with_integer_span(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    run_cmd(base_app, 'help history')
-    out = run_cmd(base_app, 'history 1..2')
-    expected = normalize("""
--------------------------[1]
-help
--------------------------[2]
-shortcuts
-""")
-    assert out == expected
-
-def test_history_with_span_start(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    run_cmd(base_app, 'help history')
-    out = run_cmd(base_app, 'history 2:')
-    expected = normalize("""
--------------------------[2]
-shortcuts
--------------------------[3]
-help history
-""")
-    assert out == expected
-
-def test_history_with_span_end(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    run_cmd(base_app, 'help history')
-    out = run_cmd(base_app, 'history :2')
-    expected = normalize("""
--------------------------[1]
-help
--------------------------[2]
-shortcuts
-""")
-    assert out == expected
-
-def test_history_with_span_index_error(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'help history')
-    run_cmd(base_app, '!ls -hal :')
-    out = run_cmd(base_app, 'history "hal :"')
-    expected = normalize("""
--------------------------[3]
-!ls -hal :
-""")
-    assert out == expected
-
-def test_history_output_file(base_app):
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'shortcuts')
-    run_cmd(base_app, 'help history')
-
-    fd, fname = tempfile.mkstemp(prefix='', suffix='.txt')
-    os.close(fd)
-    run_cmd(base_app, 'history -o "{}"'.format(fname))
-    expected = normalize('\n'.join(['help', 'shortcuts', 'help history']))
-    with open(fname) as f:
-        content = normalize(f.read())
-    assert content == expected
-
-def test_history_edit(base_app, monkeypatch):
-    # Set a fake editor just to make sure we have one.  We aren't really
-    # going to call it due to the mock
-    base_app.editor = 'fooedit'
-
-    # Mock out the subprocess.Popen call so we don't actually open an editor
-    m = mock.MagicMock(name='Popen')
-    monkeypatch.setattr("subprocess.Popen", m)
-
-    # Run help command just so we have a command in history
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'history -e 1')
-
-    # We have an editor, so should expect a Popen call
-    m.assert_called_once()
-
-def test_history_run_all_commands(base_app):
-    # make sure we refuse to run all commands as a default
-    run_cmd(base_app, 'shortcuts')
-    out = run_cmd(base_app, 'history -r')
-    # this should generate an error, but we don't currently have a way to
-    # capture stderr in these tests. So we assume that if we got nothing on
-    # standard out, that the error occurred because if the command executed
-    # then we should have a list of shortcuts in our output
-    assert out == []
-
-def test_history_run_one_command(base_app):
-    expected = run_cmd(base_app, 'help')
-    output = run_cmd(base_app, 'history -r 1')
-    assert output == expected
-
-def test_history_clear(base_app):
-    # Add commands to history
-    run_cmd(base_app, 'help')
-    run_cmd(base_app, 'alias')
-
-    # Make sure history has items
-    out = run_cmd(base_app, 'history')
-    assert out
-
-    # Clear the history
-    run_cmd(base_app, 'history --clear')
-
-    # Make sure history is empty
-    out = run_cmd(base_app, 'history')
-    assert out == []
-
-
 def test_base_load(base_app, request):
     test_dir = os.path.dirname(request.module.__file__)
     filename = os.path.join(test_dir, 'script.txt')
@@ -934,34 +730,6 @@ def test_base_py_interactive(base_app):
     m.assert_called_once()
 
 
-def test_exclude_from_history(base_app, monkeypatch):
-    # Set a fake editor just to make sure we have one.  We aren't really going to call it due to the mock
-    base_app.editor = 'fooedit'
-
-    # Mock out the subprocess.Popen call so we don't actually open an editor
-    m = mock.MagicMock(name='Popen')
-    monkeypatch.setattr("subprocess.Popen", m)
-
-    # Run edit command
-    run_cmd(base_app, 'edit')
-
-    # Run history command
-    run_cmd(base_app, 'history')
-
-    # Verify that the history is empty
-    out = run_cmd(base_app, 'history')
-    assert out == []
-
-    # Now run a command which isn't excluded from the history
-    run_cmd(base_app, 'help')
-
-    # And verify we have a history now ...
-    out = run_cmd(base_app, 'history')
-    expected = normalize("""-------------------------[1]
-help""")
-    assert out == expected
-
-
 def test_base_cmdloop_with_queue():
     # Create a cmd2.Cmd() instance and make sure basic settings are like we want for test
     app = cmd2.Cmd()
@@ -2174,20 +1942,12 @@ def test_parseline(base_app):
     assert line == statement.strip()
 
 
-def test_readline_remove_history_item(base_app):
-    from cmd2.rl_utils import readline
-    assert readline.get_current_history_length() == 0
-    readline.add_history('this is a test')
-    assert readline.get_current_history_length() == 1
-    readline.remove_history_item(0)
-    assert readline.get_current_history_length() == 0
-
 def test_onecmd_raw_str_continue(base_app):
     line = "help"
     stop = base_app.onecmd(line)
     out = base_app.stdout.getvalue()
     assert not stop
-    assert out.strip() == BASE_HELP.strip()
+    assert normalize(out) == normalize(BASE_HELP)
 
 def test_onecmd_raw_str_quit(base_app):
     line = "quit"
@@ -2197,78 +1957,6 @@ def test_onecmd_raw_str_quit(base_app):
     assert out == ''
 
 
-@pytest.fixture(scope="session")
-def hist_file():
-    fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt')
-    os.close(fd)
-    yield filename
-    # teardown code
-    try:
-        os.remove(filename)
-    except FileNotFoundError:
-        pass
-
-def test_existing_history_file(hist_file, capsys):
-    import atexit
-    import readline
-
-    # Create the history file before making cmd2 app
-    with open(hist_file, 'w'):
-        pass
-
-    # Create a new cmd2 app
-    app = cmd2.Cmd(persistent_history_file=hist_file)
-    out, err = capsys.readouterr()
-
-    # Make sure there were no errors
-    assert err == ''
-
-    # Unregister the call to write_history_file that cmd2 did
-    atexit.unregister(readline.write_history_file)
-
-    # Remove created history file
-    os.remove(hist_file)
-
-
-def test_new_history_file(hist_file, capsys):
-    import atexit
-    import readline
-
-    # Remove any existing history file
-    try:
-        os.remove(hist_file)
-    except OSError:
-        pass
-
-    # Create a new cmd2 app
-    app = cmd2.Cmd(persistent_history_file=hist_file)
-    out, err = capsys.readouterr()
-
-    # Make sure there were no errors
-    assert err == ''
-
-    # Unregister the call to write_history_file that cmd2 did
-    atexit.unregister(readline.write_history_file)
-
-    # Remove created history file
-    os.remove(hist_file)
-
-def test_bad_history_file_path(capsys, request):
-    # Use a directory path as the history file
-    test_dir = os.path.dirname(request.module.__file__)
-
-    # Create a new cmd2 app
-    app = cmd2.Cmd(persistent_history_file=test_dir)
-    out, err = capsys.readouterr()
-
-    if sys.platform == 'win32':
-        # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file.
-        assert 'readline cannot write' in err
-    else:
-        # GNU readline raises an exception upon trying to read the directory as a file
-        assert 'readline cannot read' in err
-
-
 def test_get_all_commands(base_app):
     # Verify that the base app has the expected commands
     commands = base_app.get_all_commands()
diff --git a/tests/test_history.py b/tests/test_history.py
new file mode 100644
index 00000000..06c57b4c
--- /dev/null
+++ b/tests/test_history.py
@@ -0,0 +1,410 @@
+# coding=utf-8
+# flake8: noqa E302
+"""
+Test history functions of cmd2
+"""
+import tempfile
+import os
+import sys
+
+import pytest
+
+# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available
+try:
+    import mock
+except ImportError:
+    from unittest import mock
+
+import cmd2
+from cmd2 import clipboard
+from cmd2 import utils
+from .conftest import run_cmd, normalize, HELP_HISTORY
+
+
+def test_base_help_history(base_app):
+    out = run_cmd(base_app, 'help history')
+    assert out == normalize(HELP_HISTORY)
+
+def test_exclude_from_history(base_app, monkeypatch):
+    # Set a fake editor just to make sure we have one.  We aren't really going to call it due to the mock
+    base_app.editor = 'fooedit'
+
+    # Mock out the subprocess.Popen call so we don't actually open an editor
+    m = mock.MagicMock(name='Popen')
+    monkeypatch.setattr("subprocess.Popen", m)
+
+    # Run edit command
+    run_cmd(base_app, 'edit')
+
+    # Run history command
+    run_cmd(base_app, 'history')
+
+    # Verify that the history is empty
+    out = run_cmd(base_app, 'history')
+    assert out == []
+
+    # Now run a command which isn't excluded from the history
+    run_cmd(base_app, 'help')
+
+    # And verify we have a history now ...
+    out = run_cmd(base_app, 'history')
+    expected = normalize("""    1  help""")
+    assert out == expected
+
+
+@pytest.fixture
+def hist():
+    from cmd2.parsing import Statement
+    from cmd2.cmd2 import History, HistoryItem
+    h = History([HistoryItem(Statement('', raw='first')),
+                 HistoryItem(Statement('', raw='second')),
+                 HistoryItem(Statement('', raw='third')),
+                 HistoryItem(Statement('', raw='fourth'))])
+    return h
+
+def test_history_class_span(hist):
+    h = hist
+    assert h == ['first', 'second', 'third', 'fourth']
+    assert h.span('-2..') == ['third', 'fourth']
+    assert h.span('2..3') == ['second', 'third']    # Inclusive of end
+    assert h.span('3') == ['third']
+    assert h.span(':') == h
+    assert h.span('2..') == ['second', 'third', 'fourth']
+    assert h.span('-1') == ['fourth']
+    assert h.span('-2..-3') == ['third', 'second']
+    assert h.span('*') == h
+
+def test_history_class_get(hist):
+    h = hist
+    assert h == ['first', 'second', 'third', 'fourth']
+    assert h.get('') == h
+    assert h.get('-2') == h[:-2]
+    assert h.get('5') == []
+    assert h.get('2-3') == ['second']           # Exclusive of end
+    assert h.get('ir') == ['first', 'third']    # Normal string search for all elements containing "ir"
+    assert h.get('/i.*d/') == ['third']         # Regex string search "i", then anything, then "d"
+
+def test_base_history(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    out = run_cmd(base_app, 'history')
+    expected = normalize("""
+    1  help
+    2  shortcuts
+""")
+    assert out == expected
+
+    out = run_cmd(base_app, 'history he')
+    expected = normalize("""
+    1  help
+""")
+    assert out == expected
+
+    out = run_cmd(base_app, 'history sh')
+    expected = normalize("""
+    2  shortcuts
+""")
+    assert out == expected
+
+def test_history_script_format(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    out = run_cmd(base_app, 'history -s')
+    expected = normalize("""
+help
+shortcuts
+""")
+    assert out == expected
+
+def test_history_with_string_argument(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    run_cmd(base_app, 'help history')
+    out = run_cmd(base_app, 'history help')
+    expected = normalize("""
+    1  help
+    3  help history
+""")
+    assert out == expected
+
+def test_history_expanded_with_string_argument(base_app):
+    run_cmd(base_app, 'alias create sc shortcuts')
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'help history')
+    run_cmd(base_app, 'sc')
+    out = run_cmd(base_app, 'history -v shortcuts')
+    expected = normalize("""
+    1  alias create sc shortcuts
+    4  sc
+    4x shortcuts
+""")
+    assert out == expected
+
+def test_history_expanded_with_regex_argument(base_app):
+    run_cmd(base_app, 'alias create sc shortcuts')
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'help history')
+    run_cmd(base_app, 'sc')
+    out = run_cmd(base_app, 'history -v /sh.*cuts/')
+    expected = normalize("""
+    1  alias create sc shortcuts
+    4  sc
+    4x shortcuts
+""")
+    assert out == expected
+
+def test_history_with_integer_argument(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    out = run_cmd(base_app, 'history 1')
+    expected = normalize("""
+    1  help
+""")
+    assert out == expected
+
+
+def test_history_with_integer_span(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    run_cmd(base_app, 'help history')
+    out = run_cmd(base_app, 'history 1..2')
+    expected = normalize("""
+    1  help
+    2  shortcuts
+""")
+    assert out == expected
+
+def test_history_with_span_start(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    run_cmd(base_app, 'help history')
+    out = run_cmd(base_app, 'history 2:')
+    expected = normalize("""
+    2  shortcuts
+    3  help history
+""")
+    assert out == expected
+
+def test_history_with_span_end(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    run_cmd(base_app, 'help history')
+    out = run_cmd(base_app, 'history :2')
+    expected = normalize("""
+    1  help
+    2  shortcuts
+""")
+    assert out == expected
+
+def test_history_with_span_index_error(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'help history')
+    run_cmd(base_app, '!ls -hal :')
+    out = run_cmd(base_app, 'history "hal :"')
+    expected = normalize("""
+    3  !ls -hal :
+""")
+    assert out == expected
+
+def test_history_output_file(base_app):
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'shortcuts')
+    run_cmd(base_app, 'help history')
+
+    fd, fname = tempfile.mkstemp(prefix='', suffix='.txt')
+    os.close(fd)
+    run_cmd(base_app, 'history -o "{}"'.format(fname))
+    expected = normalize('\n'.join(['help', 'shortcuts', 'help history']))
+    with open(fname) as f:
+        content = normalize(f.read())
+    assert content == expected
+
+def test_history_edit(base_app, monkeypatch):
+    # Set a fake editor just to make sure we have one.  We aren't really
+    # going to call it due to the mock
+    base_app.editor = 'fooedit'
+
+    # Mock out the Popen call so we don't actually open an editor
+    m = mock.MagicMock(name='Popen')
+    monkeypatch.setattr("subprocess.Popen", m)
+
+    # Run help command just so we have a command in history
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'history -e 1')
+
+    # We have an editor, so should expect a Popen call
+    m.assert_called_once()
+
+def test_history_run_all_commands(base_app):
+    # make sure we refuse to run all commands as a default
+    run_cmd(base_app, 'shortcuts')
+    out = run_cmd(base_app, 'history -r')
+    # this should generate an error, but we don't currently have a way to
+    # capture stderr in these tests. So we assume that if we got nothing on
+    # standard out, that the error occurred because if the command executed
+    # then we should have a list of shortcuts in our output
+    assert out == []
+
+def test_history_run_one_command(base_app):
+    expected = run_cmd(base_app, 'help')
+    output = run_cmd(base_app, 'history -r 1')
+    assert output == expected
+
+def test_history_clear(base_app):
+    # Add commands to history
+    run_cmd(base_app, 'help')
+    run_cmd(base_app, 'alias')
+
+    # Make sure history has items
+    out = run_cmd(base_app, 'history')
+    assert out
+
+    # Clear the history
+    run_cmd(base_app, 'history --clear')
+
+    # Make sure history is empty
+    out = run_cmd(base_app, 'history')
+    assert out == []
+
+def test_history_verbose_with_other_options(base_app):
+    # make sure -v shows a usage error if any other options are present
+    options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x']
+    for opt in options_to_test:
+        output = run_cmd(base_app, 'history -v ' + opt)
+        assert len(output) == 3
+        assert output[1].startswith('Usage:')
+
+def test_history_verbose(base_app):
+    # validate function of -v option
+    run_cmd(base_app, 'alias create s shortcuts')
+    run_cmd(base_app, 's')
+    output = run_cmd(base_app, 'history -v')
+    assert len(output) == 3
+    # TODO test for basic formatting once we figure it out
+
+def test_history_script_with_invalid_options(base_app):
+    # make sure -s shows a usage error if -c, -r, -e, -o, or -t are present
+    options_to_test = ['-r', '-e', '-o file', '-t file', '-c']
+    for opt in options_to_test:
+        output = run_cmd(base_app, 'history -s ' + opt)
+        assert len(output) == 3
+        assert output[1].startswith('Usage:')
+
+def test_history_script(base_app):
+    cmds = ['alias create s shortcuts', 's']
+    for cmd in cmds:
+        run_cmd(base_app, cmd)
+    output = run_cmd(base_app, 'history -s')
+    assert output == cmds
+
+def test_history_expanded_with_invalid_options(base_app):
+    # make sure -x shows a usage error if -c, -r, -e, -o, or -t are present
+    options_to_test = ['-r', '-e', '-o file', '-t file', '-c']
+    for opt in options_to_test:
+        output = run_cmd(base_app, 'history -x ' + opt)
+        assert len(output) == 3
+        assert output[1].startswith('Usage:')
+
+def test_history_expanded(base_app):
+    # validate function of -x option
+    cmds = ['alias create s shortcuts', 's']
+    for cmd in cmds:
+        run_cmd(base_app, cmd)
+    output = run_cmd(base_app, 'history -x')
+    expected = ['    1  alias create s shortcuts', '    2  shortcuts']
+    assert output == expected
+
+def test_history_script_expanded(base_app):
+    # validate function of -s -x options together
+    cmds = ['alias create s shortcuts', 's']
+    for cmd in cmds:
+        run_cmd(base_app, cmd)
+    output = run_cmd(base_app, 'history -sx')
+    expected = ['alias create s shortcuts', 'shortcuts']
+    assert output == expected
+
+
+#####
+#
+# readline tests
+#
+#####
+def test_readline_remove_history_item(base_app):
+    from cmd2.rl_utils import readline
+    assert readline.get_current_history_length() == 0
+    readline.add_history('this is a test')
+    assert readline.get_current_history_length() == 1
+    readline.remove_history_item(0)
+    assert readline.get_current_history_length() == 0
+
+
+@pytest.fixture(scope="session")
+def hist_file():
+    fd, filename = tempfile.mkstemp(prefix='hist_file', suffix='.txt')
+    os.close(fd)
+    yield filename
+    # teardown code
+    try:
+        os.remove(filename)
+    except FileNotFoundError:
+        pass
+
+def test_existing_history_file(hist_file, capsys):
+    import atexit
+    import readline
+
+    # Create the history file before making cmd2 app
+    with open(hist_file, 'w'):
+        pass
+
+    # Create a new cmd2 app
+    app = cmd2.Cmd(persistent_history_file=hist_file)
+    out, err = capsys.readouterr()
+
+    # Make sure there were no errors
+    assert err == ''
+
+    # Unregister the call to write_history_file that cmd2 did
+    atexit.unregister(readline.write_history_file)
+
+    # Remove created history file
+    os.remove(hist_file)
+
+def test_new_history_file(hist_file, capsys):
+    import atexit
+    import readline
+
+    # Remove any existing history file
+    try:
+        os.remove(hist_file)
+    except OSError:
+        pass
+
+    # Create a new cmd2 app
+    app = cmd2.Cmd(persistent_history_file=hist_file)
+    out, err = capsys.readouterr()
+
+    # Make sure there were no errors
+    assert err == ''
+
+    # Unregister the call to write_history_file that cmd2 did
+    atexit.unregister(readline.write_history_file)
+
+    # Remove created history file
+    os.remove(hist_file)
+
+def test_bad_history_file_path(capsys, request):
+    # Use a directory path as the history file
+    test_dir = os.path.dirname(request.module.__file__)
+
+    # Create a new cmd2 app
+    app = cmd2.Cmd(persistent_history_file=test_dir)
+    out, err = capsys.readouterr()
+
+    if sys.platform == 'win32':
+        # pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file.
+        assert 'readline cannot write' in err
+    else:
+        # GNU readline raises an exception upon trying to read the directory as a file
+        assert 'readline cannot read' in err
+
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index de49d3f5..85ee0765 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -10,8 +10,9 @@ import attr
 import pytest
 
 import cmd2
-from cmd2.parsing import StatementParser
 from cmd2 import constants, utils
+from cmd2.constants import MULTILINE_TERMINATOR
+from cmd2.parsing import StatementParser
 
 @pytest.fixture
 def parser():
@@ -147,6 +148,7 @@ def test_parse_word_plus_terminator(parser, line, terminator):
     assert statement.argv == ['termbare']
     assert not statement.arg_list
     assert statement.terminator == terminator
+    assert statement.expanded_command_line == statement.command + statement.terminator
 
 @pytest.mark.parametrize('line,terminator', [
     ('termbare;  suffx', ';'),
@@ -163,6 +165,7 @@ def test_parse_suffix_after_terminator(parser, line, terminator):
     assert not statement.arg_list
     assert statement.terminator == terminator
     assert statement.suffix == 'suffx'
+    assert statement.expanded_command_line == statement.command + statement.terminator + ' ' + statement.suffix
 
 def test_parse_command_with_args(parser):
     line = 'command with args'
@@ -222,6 +225,7 @@ def test_parse_simple_pipe(parser, line):
     assert statement.argv == ['simple']
     assert not statement.arg_list
     assert statement.pipe_to == ['piped']
+    assert statement.expanded_command_line == statement.command + ' | ' + ' '.join(statement.pipe_to)
 
 def test_parse_double_pipe_is_not_a_pipe(parser):
     line = 'double-pipe || is not a pipe'
@@ -258,6 +262,7 @@ def test_parse_redirect(parser,line, output):
     assert statement.args == statement
     assert statement.output == output
     assert statement.output_to == 'out.txt'
+    assert statement.expanded_command_line == statement.command + ' ' + statement.output + ' ' + statement.output_to
 
 def test_parse_redirect_with_args(parser):
     line = 'output into > afile.txt'
@@ -479,6 +484,7 @@ def test_parse_alias_on_multiline_command(parser):
     assert statement.args == statement
     assert statement == 'has > inside an unfinished command'
     assert statement.terminator == ''
+    assert statement.expanded_command_line == statement.multiline_command + ' ' + statement + MULTILINE_TERMINATOR
 
 @pytest.mark.parametrize('line,output', [
     ('helpalias > out.txt', '>'),
diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt
index 8c0dd007..871b71f1 100644
--- a/tests/transcripts/from_cmdloop.txt
+++ b/tests/transcripts/from_cmdloop.txt
@@ -35,18 +35,12 @@ OODNIGHT, GRACIEGAY
 OODNIGHT, GRACIEGAY
 OODNIGHT, GRACIEGAY
 (Cmd) history
--------------------------[1]
-help
--------------------------[2]
-help say
--------------------------[3]
-say goodnight, Gracie
--------------------------[4]
-say -ps --repeat=5 goodnight, Gracie
--------------------------[5]
-set maxrepeats 5
--------------------------[6]
-say -ps --repeat=5 goodnight, Gracie
+    1  help
+    2  help say
+    3  say goodnight, Gracie
+    4  say -ps --repeat=5 goodnight, Gracie
+    5  set maxrepeats 5
+    6  say -ps --repeat=5 goodnight, Gracie
 (Cmd) history -r 4
 say -ps --repeat=5 goodnight, Gracie
 OODNIGHT, GRACIEGAY