diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c25bc9b..630f6892 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.9.21 (TBD, 2019)
+* Bug Fixes
+    * Fixed bug where pipe processes were not being stopped by Ctrl-C on Linux/Mac
+* Enhancements
+    * Added `read_input()` function that is used to read from stdin. Unlike the Python built-in `input()`, it also has
+    an argument to disable tab completion while input is being entered.
+
 ## 0.9.20 (November 12, 2019)
 * Bug Fixes
     * Fixed bug where setting `use_ipython` to False removed ipy command from the entire `cmd2.Cmd` class instead of
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index e50d9512..051e6f7c 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -416,6 +416,10 @@ class Cmd(cmd.Cmd):
             self.perror('Invalid value: {} (valid values: {}, {}, {})'.format(new_val, ansi.ANSI_TERMINAL,
                                                                               ansi.ANSI_ALWAYS, ansi.ANSI_NEVER))
 
+    def _completion_supported(self) -> bool:
+        """Return whether tab completion is supported"""
+        return self.use_rawinput and self.completekey and rl_type != RlType.NONE
+
     @property
     def visible_prompt(self) -> str:
         """Read-only property to get the visible prompt with any ANSI escape codes stripped.
@@ -1322,7 +1326,7 @@ class Cmd(cmd.Cmd):
         """
         # noinspection PyBroadException
         try:
-            if state == 0 and rl_type != RlType.NONE:
+            if state == 0:
                 self._reset_completion_defaults()
 
                 # Check if we are completing a multiline command
@@ -1649,7 +1653,7 @@ class Cmd(cmd.Cmd):
         """Keep accepting lines of input until the command is complete.
 
         There is some pretty hacky code here to handle some quirks of
-        self._pseudo_raw_input(). It returns a literal 'eof' if the input
+        self._read_command_line(). It returns a literal 'eof' if the input
         pipe runs out. We can't refactor it because we need to retain
         backwards compatibility with the standard library version of cmd.
 
@@ -1683,7 +1687,7 @@ class Cmd(cmd.Cmd):
                 # Save the command line up to this point for tab completion
                 self._multiline_in_progress = line + '\n'
 
-                nextline = self._pseudo_raw_input(self.continuation_prompt)
+                nextline = self._read_command_line(self.continuation_prompt)
                 if nextline == 'eof':
                     # they entered either a blank line, or we hit an EOF
                     # for some other reason. Turn the literal 'eof'
@@ -1989,36 +1993,59 @@ class Cmd(cmd.Cmd):
             # Set apply_style to False so default_error's style is not overridden
             self.perror(err_msg, apply_style=False)
 
-    def _pseudo_raw_input(self, prompt: str) -> str:
-        """Began life as a copy of cmd's cmdloop; like raw_input but
-
-        - accounts for changed stdin, stdout
-        - if input is a pipe (instead of a tty), look at self.echo
-          to decide whether to print the prompt and the input
+    def read_input(self, prompt: str, *, allow_completion: bool = False) -> str:
         """
-        if self.use_rawinput:
-            try:
-                if sys.stdin.isatty():
-                    # Wrap in try since terminal_lock may not be locked when this function is called from unit tests
-                    try:
-                        # A prompt is about to be drawn. Allow asynchronous changes to the terminal.
-                        self.terminal_lock.release()
-                    except RuntimeError:
-                        pass
+        Read input from appropriate stdin value. Also allows you to disable tab completion while input is being read.
+        :param prompt: prompt to display to user
+        :param allow_completion: if True, then tab completion of commands is enabled. This generally should be
+        set to False unless reading the command line. Defaults to False.
+        :return: the line read from stdin with all trailing new lines removed
+        :raises any exceptions raised by input() and stdin.readline()
+        """
+        completion_disabled = False
+        orig_completer = None
+
+        def disable_completion():
+            """Turn off completion while entering input"""
+            nonlocal orig_completer
+            nonlocal completion_disabled
+
+            if self._completion_supported() and not completion_disabled:
+                orig_completer = readline.get_completer()
+                readline.set_completer(lambda *args, **kwargs: None)
+                completion_disabled = True
+
+        def enable_completion():
+            """Restore tab completion when finished entering input"""
+            nonlocal completion_disabled
+
+            if self._completion_supported() and completion_disabled:
+                readline.set_completer(orig_completer)
+                completion_disabled = False
 
+        # Check we are reading from sys.stdin
+        if self.use_rawinput:
+            if sys.stdin.isatty():
+                try:
                     # Deal with the vagaries of readline and ANSI escape codes
                     safe_prompt = rl_make_safe_prompt(prompt)
+
+                    with self.sigint_protection:
+                        # Check if tab completion should be disabled
+                        if not allow_completion:
+                            disable_completion()
                     line = input(safe_prompt)
-                else:
-                    line = input()
-                    if self.echo:
-                        sys.stdout.write('{}{}\n'.format(prompt, line))
-            except EOFError:
-                line = 'eof'
-            finally:
-                if sys.stdin.isatty():
-                    # The prompt is gone. Do not allow asynchronous changes to the terminal.
-                    self.terminal_lock.acquire()
+                finally:
+                    with self.sigint_protection:
+                        # Check if we need to re-enable tab completion
+                        if not allow_completion:
+                            enable_completion()
+            else:
+                line = input()
+                if self.echo:
+                    sys.stdout.write('{}{}\n'.format(prompt, line))
+
+        # Otherwise read from self.stdin
         else:
             if self.stdin.isatty():
                 # on a tty, print the prompt first, then read the line
@@ -2041,6 +2068,28 @@ class Cmd(cmd.Cmd):
 
         return line.rstrip('\r\n')
 
+    def _read_command_line(self, prompt: str) -> str:
+        """
+        Read command line from appropriate stdin
+
+        :param prompt: prompt to display to user
+        :return: command line text of 'eof' if an EOFError was caught
+        :raises whatever exceptions are raised by input() except for EOFError
+        """
+        try:
+            # Wrap in try since terminal_lock may not be locked
+            try:
+                # Command line is about to be drawn. Allow asynchronous changes to the terminal.
+                self.terminal_lock.release()
+            except RuntimeError:
+                pass
+            return self.read_input(prompt, allow_completion=True)
+        except EOFError:
+            return 'eof'
+        finally:
+            # Command line is gone. Do not allow asynchronous changes to the terminal.
+            self.terminal_lock.acquire()
+
     def _set_up_cmd2_readline(self) -> _SavedReadlineSettings:
         """
         Set up readline with cmd2-specific settings
@@ -2048,7 +2097,7 @@ class Cmd(cmd.Cmd):
         """
         readline_settings = _SavedReadlineSettings()
 
-        if self.use_rawinput and self.completekey and rl_type != RlType.NONE:
+        if self._completion_supported():
 
             # Set up readline for our tab completion needs
             if rl_type == RlType.GNU:
@@ -2080,7 +2129,7 @@ class Cmd(cmd.Cmd):
         Restore saved readline settings
         :param readline_settings: the readline settings to restore
         """
-        if self.use_rawinput and self.completekey and rl_type != RlType.NONE:
+        if self._completion_supported():
 
             # Restore what we changed in readline
             readline.set_completer(readline_settings.completer)
@@ -2114,7 +2163,7 @@ class Cmd(cmd.Cmd):
             while not stop:
                 # Get commands from user
                 try:
-                    line = self._pseudo_raw_input(self.prompt)
+                    line = self._read_command_line(self.prompt)
                 except KeyboardInterrupt as ex:
                     if self.quit_on_sigint:
                         raise ex
@@ -2693,27 +2742,6 @@ class Cmd(cmd.Cmd):
                                    that the return value can differ from
                                    the text advertised to the user """
 
-        completion_disabled = False
-        orig_completer = None
-
-        def disable_completion():
-            """Turn off completion during the select input line"""
-            nonlocal orig_completer
-            nonlocal completion_disabled
-
-            if rl_type != RlType.NONE and not completion_disabled:
-                orig_completer = readline.get_completer()
-                readline.set_completer(lambda *args, **kwargs: None)
-                completion_disabled = True
-
-        def enable_completion():
-            """Restore tab completion when select is done reading input"""
-            nonlocal completion_disabled
-
-            if rl_type != RlType.NONE and completion_disabled:
-                readline.set_completer(orig_completer)
-                completion_disabled = False
-
         local_opts = opts
         if isinstance(opts, str):
             local_opts = list(zip(opts.split(), opts.split()))
@@ -2730,18 +2758,14 @@ class Cmd(cmd.Cmd):
             self.poutput('  %2d. %s' % (idx + 1, text))
 
         while True:
-            safe_prompt = rl_make_safe_prompt(prompt)
-
             try:
-                with self.sigint_protection:
-                    disable_completion()
-                response = input(safe_prompt)
+                response = self.read_input(prompt)
             except EOFError:
                 response = ''
                 self.poutput('\n', end='')
-            finally:
-                with self.sigint_protection:
-                    enable_completion()
+            except KeyboardInterrupt as ex:
+                self.poutput('^C')
+                raise ex
 
             if not response:
                 continue
@@ -2921,7 +2945,7 @@ class Cmd(cmd.Cmd):
             for item in self._py_history:
                 readline.add_history(item)
 
-            if self.use_rawinput and self.completekey:
+            if self._completion_supported():
                 # Set up tab completion for the Python console
                 # rlcompleter relies on the default settings of the Python readline module
                 if rl_type == RlType.GNU:
@@ -2988,7 +3012,7 @@ class Cmd(cmd.Cmd):
             for item in cmd2_env.history:
                 readline.add_history(item)
 
-            if self.use_rawinput and self.completekey:
+            if self._completion_supported():
                 # Restore cmd2's tab completion settings
                 readline.set_completer(cmd2_env.readline_settings.completer)
                 readline.set_completer_delims(cmd2_env.readline_settings.delims)
@@ -3715,7 +3739,7 @@ class Cmd(cmd.Cmd):
 
     def async_alert(self, alert_msg: str, new_prompt: Optional[str] = None) -> None:  # pragma: no cover
         """
-        Display an important message to the user while they are at the prompt in between commands.
+        Display an important message to the user while they are at a command line prompt.
         To the user it appears as if an alert message is printed above the prompt and their current input
         text and cursor location is left alone.
 
@@ -3775,10 +3799,10 @@ class Cmd(cmd.Cmd):
 
     def async_update_prompt(self, new_prompt: str) -> None:  # pragma: no cover
         """
-        Update the prompt while the user is still typing at it. This is good for alerting the user to system
-        changes dynamically in between commands. For instance you could alter the color of the prompt to indicate
-        a system status or increase a counter to report an event. If you do alter the actual text of the prompt,
-        it is best to keep the prompt the same width as what's on screen. Otherwise the user's input text will
+        Update the command line prompt while the user is still typing at it. This is good for alerting the user to
+        system changes dynamically in between commands. For instance you could alter the color of the prompt to
+        indicate a system status or increase a counter to report an event. If you do alter the actual text of the
+        prompt, it is best to keep the prompt the same width as what's on screen. Otherwise the user's input text will
         be shifted and the update will not be seamless.
 
         Raises a `RuntimeError` if called while another thread holds `terminal_lock`.
@@ -3948,7 +3972,7 @@ class Cmd(cmd.Cmd):
         original_sigint_handler = signal.getsignal(signal.SIGINT)
         signal.signal(signal.SIGINT, self.sigint_handler)
 
-        # Grab terminal lock before the prompt has been drawn by readline
+        # Grab terminal lock before the command line prompt has been drawn by readline
         self.terminal_lock.acquire()
 
         # Always run the preloop first
diff --git a/cmd2/utils.py b/cmd2/utils.py
index 88fbc1da..3155c64a 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -520,10 +520,15 @@ class ProcReader(object):
         """Send a SIGINT to the process similar to if <Ctrl>+C were pressed."""
         import signal
         if sys.platform.startswith('win'):
-            signal_to_send = signal.CTRL_C_EVENT
+            self._proc.send_signal(signal.CTRL_C_EVENT)
         else:
-            signal_to_send = signal.SIGINT
-        self._proc.send_signal(signal_to_send)
+            # Since cmd2 uses shell=True in its Popen calls, we need to send the SIGINT to
+            # the whole process group to make sure it propagates further than the shell
+            try:
+                group_id = os.getpgid(self._proc.pid)
+                os.killpg(group_id, signal.SIGINT)
+            except ProcessLookupError:
+                return
 
     def terminate(self) -> None:
         """Terminate the process"""

Create cmd2-specific input() function
Provide developers a function for reading input with the following signature:

`def read_input(self, prompt: str, enable_completion: bool = False) -> str`

The name isn't set in stone.

Difference between this function and the existing `_pseudo_read_input`

1. Raises `EOFError` exceptions instead returning the string `eof`
2. Turns off tab completion by default

`_pseudo_read_input` will be replaced by something similar to the following:

```Python
def _read_command_line(self, prompt: str) -> str:
    try:
        # Wrap in try since terminal_lock may not be locked
        try:
            # Command line is about to be drawn. Allow asynchronous changes to the terminal.
            self.terminal_lock.release()
        except RuntimeError:
            pass
        return self.read_input(prompt, enable_completion=True)
    except EOFError:
        return 'eof'
    finally:
        # Command line is gone. Do not allow asynchronous changes to the terminal.
        self.terminal_lock.acquire()
```
        
    
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index cb66ac9b..f9c3e61d 100755
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -847,6 +847,18 @@ def test_cmdloop_without_rawinput():
     out = app.stdout.getvalue()
     assert out == expected
 
+@pytest.mark.skipif(sys.platform.startswith('win'),
+                    reason="stty sane only run on Linux/Mac")
+def test_stty_sane(base_app, monkeypatch):
+    """Make sure stty sane is run on Linux/Mac after each command if stdin is a terminal"""
+    with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)):
+        # Mock out the subprocess.Popen call so we don't actually run stty sane
+        m = mock.MagicMock(name='Popen')
+        monkeypatch.setattr("subprocess.Popen", m)
+
+        base_app.onecmd_plus_hooks('help')
+        m.assert_called_once_with(['stty', 'sane'])
+
 class HookFailureApp(cmd2.Cmd):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -1092,10 +1104,10 @@ def select_app():
     app = SelectApp()
     return app
 
-def test_select_options(select_app):
-    # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.MagicMock(name='input', return_value='2')
-    builtins.input = m
+def test_select_options(select_app, monkeypatch):
+    # Mock out the read_input call so we don't actually wait for a user's response on stdin
+    read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     food = 'bacon'
     out, err = run_cmd(select_app, "eat {}".format(food))
@@ -1106,17 +1118,18 @@ def test_select_options(select_app):
 """.format(food))
 
     # Make sure our mock was called with the expected arguments
-    m.assert_called_once_with('Sauce? ')
+    read_input_mock.assert_called_once_with('Sauce? ')
 
     # And verify the expected output to stdout
     assert out == expected
 
-def test_select_invalid_option_too_big(select_app):
+def test_select_invalid_option_too_big(select_app, monkeypatch):
     # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.MagicMock(name='input')
+    read_input_mock = mock.MagicMock(name='read_input')
+
     # If side_effect is an iterable then each call to the mock will return the next value from the iterable.
-    m.side_effect = ['3', '1']  # First pass an invalid selection, then pass a valid one
-    builtins.input = m
+    read_input_mock.side_effect = ['3', '1']  # First pass an invalid selection, then pass a valid one
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     food = 'fish'
     out, err = run_cmd(select_app, "eat {}".format(food))
@@ -1130,18 +1143,19 @@ def test_select_invalid_option_too_big(select_app):
     # Make sure our mock was called exactly twice with the expected arguments
     arg = 'Sauce? '
     calls = [mock.call(arg), mock.call(arg)]
-    m.assert_has_calls(calls)
-    assert m.call_count == 2
+    read_input_mock.assert_has_calls(calls)
+    assert read_input_mock.call_count == 2
 
     # And verify the expected output to stdout
     assert out == expected
 
-def test_select_invalid_option_too_small(select_app):
+def test_select_invalid_option_too_small(select_app, monkeypatch):
     # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.MagicMock(name='input')
+    read_input_mock = mock.MagicMock(name='read_input')
+
     # If side_effect is an iterable then each call to the mock will return the next value from the iterable.
-    m.side_effect = ['0', '1']  # First pass an invalid selection, then pass a valid one
-    builtins.input = m
+    read_input_mock.side_effect = ['0', '1']  # First pass an invalid selection, then pass a valid one
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     food = 'fish'
     out, err = run_cmd(select_app, "eat {}".format(food))
@@ -1155,16 +1169,16 @@ def test_select_invalid_option_too_small(select_app):
     # Make sure our mock was called exactly twice with the expected arguments
     arg = 'Sauce? '
     calls = [mock.call(arg), mock.call(arg)]
-    m.assert_has_calls(calls)
-    assert m.call_count == 2
+    read_input_mock.assert_has_calls(calls)
+    assert read_input_mock.call_count == 2
 
     # And verify the expected output to stdout
     assert out == expected
 
-def test_select_list_of_strings(select_app):
+def test_select_list_of_strings(select_app, monkeypatch):
     # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.MagicMock(name='input', return_value='2')
-    builtins.input = m
+    read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     out, err = run_cmd(select_app, "study")
     expected = normalize("""
@@ -1174,15 +1188,15 @@ Good luck learning {}!
 """.format('science'))
 
     # Make sure our mock was called with the expected arguments
-    m.assert_called_once_with('Subject? ')
+    read_input_mock.assert_called_once_with('Subject? ')
 
     # And verify the expected output to stdout
     assert out == expected
 
-def test_select_list_of_tuples(select_app):
+def test_select_list_of_tuples(select_app, monkeypatch):
     # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.MagicMock(name='input', return_value='2')
-    builtins.input = m
+    read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     out, err = run_cmd(select_app, "procrastinate")
     expected = normalize("""
@@ -1192,16 +1206,16 @@ Have fun procrasinating with {}!
 """.format('YouTube'))
 
     # Make sure our mock was called with the expected arguments
-    m.assert_called_once_with('How would you like to procrastinate? ')
+    read_input_mock.assert_called_once_with('How would you like to procrastinate? ')
 
     # And verify the expected output to stdout
     assert out == expected
 
 
-def test_select_uneven_list_of_tuples(select_app):
+def test_select_uneven_list_of_tuples(select_app, monkeypatch):
     # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.MagicMock(name='input', return_value='2')
-    builtins.input = m
+    read_input_mock = mock.MagicMock(name='read_input', return_value='2')
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     out, err = run_cmd(select_app, "play")
     expected = normalize("""
@@ -1211,15 +1225,15 @@ Charm us with the {}...
 """.format('Drums'))
 
     # Make sure our mock was called with the expected arguments
-    m.assert_called_once_with('Instrument? ')
+    read_input_mock.assert_called_once_with('Instrument? ')
 
     # And verify the expected output to stdout
     assert out == expected
 
-def test_select_eof(select_app):
+def test_select_eof(select_app, monkeypatch):
     # Ctrl-D during select causes an EOFError that just reprompts the user
-    m = mock.MagicMock(name='input', side_effect=[EOFError, 2])
-    builtins.input = m
+    read_input_mock = mock.MagicMock(name='read_input', side_effect=[EOFError, 2])
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
     food = 'fish'
     out, err = run_cmd(select_app, "eat {}".format(food))
@@ -1227,8 +1241,19 @@ def test_select_eof(select_app):
     # Make sure our mock was called exactly twice with the expected arguments
     arg = 'Sauce? '
     calls = [mock.call(arg), mock.call(arg)]
-    m.assert_has_calls(calls)
-    assert m.call_count == 2
+    read_input_mock.assert_has_calls(calls)
+    assert read_input_mock.call_count == 2
+
+def test_select_ctrl_c(outsim_app, monkeypatch, capsys):
+    # Ctrl-C during select prints ^C and raises a KeyboardInterrupt
+    read_input_mock = mock.MagicMock(name='read_input', side_effect=KeyboardInterrupt)
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
+
+    with pytest.raises(KeyboardInterrupt):
+        outsim_app.select([('Guitar', 'Electric Guitar'), ('Drums',)], 'Instrument? ')
+
+    out = outsim_app.stdout.getvalue()
+    assert out.rstrip().endswith('^C')
 
 class HelpNoDocstringApp(cmd2.Cmd):
     greet_parser = argparse.ArgumentParser()
@@ -1419,131 +1444,94 @@ def test_echo(capsys):
     out, err = capsys.readouterr()
     assert out.startswith('{}{}\n'.format(app.prompt, commands[0]) + HELP_HISTORY.split()[0])
 
-def test_pseudo_raw_input_tty_rawinput_true():
-    # use context managers so original functions get put back when we are done
-    # we dont use decorators because we need m_input for the assertion
-    with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)):
-        with mock.patch('builtins.input', mock.MagicMock(name='input', side_effect=['set', EOFError])) as m_input:
-            # run the cmdloop, which should pull input from our mocks
-            app = cmd2.Cmd(allow_cli_args=False)
-            app.use_rawinput = True
-            app._cmdloop()
-            # because we mocked the input() call, we won't get the prompt
-            # or the name of the command in the output, so we can't check
-            # if its there. We assume that if input got called twice, once
-            # for the 'set' command, and once for the 'quit' command,
-            # that the rest of it worked
-            assert m_input.call_count == 2
-
-def test_pseudo_raw_input_tty_rawinput_false():
-    # gin up some input like it's coming from a tty
-    fakein = io.StringIO(u'{}'.format('set\n'))
-    mtty = mock.MagicMock(name='isatty', return_value=True)
-    fakein.isatty = mtty
-    mreadline = mock.MagicMock(name='readline', wraps=fakein.readline)
-    fakein.readline = mreadline
-
-    # run the cmdloop, telling it where to get input from
-    app = cmd2.Cmd(stdin=fakein, allow_cli_args=False)
-    app.use_rawinput = False
-    app._cmdloop()
-
-    # because we mocked the readline() call, we won't get the prompt
-    # or the name of the command in the output, so we can't check
-    # if its there. We assume that if readline() got called twice, once
-    # for the 'set' command, and once for the 'quit' command,
-    # that the rest of it worked
-    assert mreadline.call_count == 2
-
-# the next helper function and two tests check for piped
-# input when use_rawinput is True.
-def piped_rawinput_true(capsys, echo, command):
-    app = cmd2.Cmd(allow_cli_args=False)
-    app.use_rawinput = True
-    app.echo = echo
-    # run the cmdloop, which should pull input from our mock
-    app._cmdloop()
-    out, err = capsys.readouterr()
-    return app, out
-
-# using the decorator puts the original input function back when this unit test returns
-@mock.patch('builtins.input', mock.MagicMock(name='input', side_effect=['set', EOFError]))
-def test_pseudo_raw_input_piped_rawinput_true_echo_true(capsys):
-    command = 'set'
-    app, out = piped_rawinput_true(capsys, True, command)
-    out = out.splitlines()
-    assert out[0] == '{}{}'.format(app.prompt, command)
-    assert out[1].startswith('allow_ansi:')
-
-# using the decorator puts the original input function back when this unit test returns
-@mock.patch('builtins.input', mock.MagicMock(name='input', side_effect=['set', EOFError]))
-def test_pseudo_raw_input_piped_rawinput_true_echo_false(capsys):
-    command = 'set'
-    app, out = piped_rawinput_true(capsys, False, command)
-    firstline = out.splitlines()[0]
-    assert firstline.startswith('allow_ansi:')
-    assert not '{}{}'.format(app.prompt, command) in out
-
-# the next helper function and two tests check for piped
-# input when use_rawinput=False
-def piped_rawinput_false(capsys, echo, command):
-    fakein = io.StringIO(u'{}'.format(command))
-    app = cmd2.Cmd(stdin=fakein, allow_cli_args=False)
-    app.use_rawinput = False
-    app.echo = echo
-    app._cmdloop()
-    out, err = capsys.readouterr()
-    return app, out
-
-def test_pseudo_raw_input_piped_rawinput_false_echo_true(capsys):
-    command = 'set'
-    app, out = piped_rawinput_false(capsys, True, command)
-    out = out.splitlines()
-    assert out[0] == '{}{}'.format(app.prompt, command)
-    assert out[1].startswith('allow_ansi:')
+def test_read_input_rawinput_true(capsys, monkeypatch):
+    prompt_str = 'the_prompt'
+    input_str = 'some input'
 
-def test_pseudo_raw_input_piped_rawinput_false_echo_false(capsys):
-    command = 'set'
-    app, out = piped_rawinput_false(capsys, False, command)
-    firstline = out.splitlines()[0]
-    assert firstline.startswith('allow_ansi:')
-    assert not '{}{}'.format(app.prompt, command) in out
-
-
-# other input tests
-def test_raw_input(base_app):
-    base_app.use_raw_input = True
-    fake_input = 'quit'
+    app = cmd2.Cmd()
+    app.use_rawinput = True
 
-    # Mock out the input call so we don't actually wait for a user's response on stdin
-    m = mock.Mock(name='input', return_value=fake_input)
-    builtins.input = m
+    # Mock out input() to return input_str
+    monkeypatch.setattr("builtins.input", lambda *args: input_str)
 
-    line = base_app._pseudo_raw_input('(cmd2)')
-    assert line == fake_input
+    # isatty is True
+    with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=True)):
+        line = app.read_input(prompt_str)
+        assert line == input_str
+
+    # isatty is False
+    with mock.patch('sys.stdin.isatty', mock.MagicMock(name='isatty', return_value=False)):
+        # echo True
+        app.echo = True
+        line = app.read_input(prompt_str)
+        out, err = capsys.readouterr()
+        assert line == input_str
+        assert out == "{}{}\n".format(prompt_str, input_str)
+
+        # echo False
+        app.echo = False
+        line = app.read_input(prompt_str)
+        out, err = capsys.readouterr()
+        assert line == input_str
+        assert not out
+
+def test_read_input_rawinput_false(capsys, monkeypatch):
+    prompt_str = 'the_prompt'
+    input_str = 'some input'
+
+    def make_app(isatty: bool, empty_input: bool = False):
+        """Make a cmd2 app with a custom stdin"""
+        app_input_str = '' if empty_input else input_str
+
+        fakein = io.StringIO('{}'.format(app_input_str))
+        fakein.isatty = mock.MagicMock(name='isatty', return_value=isatty)
+
+        new_app = cmd2.Cmd(stdin=fakein)
+        new_app.use_rawinput = False
+        return new_app
+
+    # isatty True
+    app = make_app(isatty=True)
+    line = app.read_input(prompt_str)
+    out, err = capsys.readouterr()
+    assert line == input_str
+    assert out == prompt_str
 
-def test_stdin_input():
-    app = cmd2.Cmd()
-    app.use_rawinput = False
-    fake_input = 'quit'
+    # isatty True, empty input
+    app = make_app(isatty=True, empty_input=True)
+    line = app.read_input(prompt_str)
+    out, err = capsys.readouterr()
+    assert line == 'eof'
+    assert out == prompt_str
 
-    # Mock out the readline call so we don't actually read from stdin
-    m = mock.Mock(name='readline', return_value=fake_input)
-    app.stdin.readline = m
+    # isatty is False, echo is True
+    app = make_app(isatty=False)
+    app.echo = True
+    line = app.read_input(prompt_str)
+    out, err = capsys.readouterr()
+    assert line == input_str
+    assert out == "{}{}\n".format(prompt_str, input_str)
 
-    line = app._pseudo_raw_input('(cmd2)')
-    assert line == fake_input
+    # isatty is False, echo is False
+    app = make_app(isatty=False)
+    app.echo = False
+    line = app.read_input(prompt_str)
+    out, err = capsys.readouterr()
+    assert line == input_str
+    assert not out
 
-def test_empty_stdin_input():
-    app = cmd2.Cmd()
-    app.use_rawinput = False
-    fake_input = ''
+    # isatty is False, empty input
+    app = make_app(isatty=False, empty_input=True)
+    line = app.read_input(prompt_str)
+    out, err = capsys.readouterr()
+    assert line == 'eof'
+    assert not out
 
-    # Mock out the readline call so we don't actually read from stdin
-    m = mock.Mock(name='readline', return_value=fake_input)
-    app.stdin.readline = m
+def test_read_command_line_eof(base_app, monkeypatch):
+    read_input_mock = mock.MagicMock(name='read_input', side_effect=EOFError)
+    monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)
 
-    line = app._pseudo_raw_input('(cmd2)')
+    line = base_app._read_command_line("Prompt> ")
     assert line == 'eof'
 
 def test_poutput_string(outsim_app):

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-mock
apt-get update apt-get install -y gcc
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.16
anyio==4.9.0
attrs==25.3.0
babel==2.17.0
backports.tarfile==1.2.0
cachetools==5.5.2
certifi==2025.1.31
cffi==1.17.1
chardet==5.2.0
charset-normalizer==3.4.1
click==8.1.8
-e git+https://github.com/python-cmd2/cmd2.git@0fc04d2091069ddabf776bd9fddf0408b81e57af#egg=cmd2
codecov==2.1.13
colorama==0.4.6
coverage==7.8.0
cryptography==44.0.2
distlib==0.3.9
doc8==1.1.2
docutils==0.21.2
exceptiongroup==1.2.2
filelock==3.18.0
flake8==7.2.0
h11==0.14.0
id==1.5.0
idna==3.10
imagesize==1.4.1
importlib_metadata==8.6.1
iniconfig==2.1.0
invoke==2.2.0
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.1.0
jeepney==0.9.0
Jinja2==3.1.6
keyring==25.6.0
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mccabe==0.7.0
mdurl==0.1.2
more-itertools==10.6.0
nh3==0.2.21
packaging==24.2
pbr==6.1.1
platformdirs==4.3.7
pluggy==1.5.0
pycodestyle==2.13.0
pycparser==2.22
pyflakes==3.3.1
Pygments==2.19.1
pyperclip==1.9.0
pyproject-api==1.9.0
pytest==8.3.5
pytest-cov==6.0.0
pytest-mock==3.14.0
readme_renderer==44.0
requests==2.32.3
requests-toolbelt==1.0.0
restructuredtext_lint==1.4.0
rfc3986==2.0.0
rich==14.0.0
SecretStorage==3.3.3
sniffio==1.3.1
snowballstemmer==2.2.0
Sphinx==7.4.7
sphinx-autobuild==2024.10.3
sphinx-rtd-theme==3.0.2
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
starlette==0.46.1
stevedore==5.4.1
tomli==2.2.1
tox==4.25.0
twine==6.1.0
typing_extensions==4.13.0
urllib3==2.3.0
uvicorn==0.34.0
virtualenv==20.29.3
watchfiles==1.0.4
wcwidth==0.2.13
websockets==15.0.1
zipp==3.21.0

name: cmd2
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.16
      - anyio==4.9.0
      - attrs==25.3.0
      - babel==2.17.0
      - backports-tarfile==1.2.0
      - cachetools==5.5.2
      - certifi==2025.1.31
      - cffi==1.17.1
      - chardet==5.2.0
      - charset-normalizer==3.4.1
      - click==8.1.8
      - codecov==2.1.13
      - colorama==0.4.6
      - coverage==7.8.0
      - cryptography==44.0.2
      - distlib==0.3.9
      - doc8==1.1.2
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - flake8==7.2.0
      - h11==0.14.0
      - id==1.5.0
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - invoke==2.2.0
      - jaraco-classes==3.4.0
      - jaraco-context==6.0.1
      - jaraco-functools==4.1.0
      - jeepney==0.9.0
      - jinja2==3.1.6
      - keyring==25.6.0
      - markdown-it-py==3.0.0
      - markupsafe==3.0.2
      - mccabe==0.7.0
      - mdurl==0.1.2
      - more-itertools==10.6.0
      - nh3==0.2.21
      - packaging==24.2
      - pbr==6.1.1
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pycparser==2.22
      - pyflakes==3.3.1
      - pygments==2.19.1
      - pyperclip==1.9.0
      - pyproject-api==1.9.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - pytest-mock==3.14.0
      - readme-renderer==44.0
      - requests==2.32.3
      - requests-toolbelt==1.0.0
      - restructuredtext-lint==1.4.0
      - rfc3986==2.0.0
      - rich==14.0.0
      - secretstorage==3.3.3
      - sniffio==1.3.1
      - snowballstemmer==2.2.0
      - sphinx==7.4.7
      - sphinx-autobuild==2024.10.3
      - sphinx-rtd-theme==3.0.2
      - sphinxcontrib-applehelp==2.0.0
      - sphinxcontrib-devhelp==2.0.0
      - sphinxcontrib-htmlhelp==2.1.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==2.0.0
      - sphinxcontrib-serializinghtml==2.0.0
      - starlette==0.46.1
      - stevedore==5.4.1
      - tomli==2.2.1
      - tox==4.25.0
      - twine==6.1.0
      - typing-extensions==4.13.0
      - urllib3==2.3.0
      - uvicorn==0.34.0
      - virtualenv==20.29.3
      - watchfiles==1.0.4
      - wcwidth==0.2.13
      - websockets==15.0.1
      - zipp==3.21.0
prefix: /opt/conda/envs/cmd2

tests/test_cmd2.py::test_select_options tests/test_cmd2.py::test_select_invalid_option_too_big tests/test_cmd2.py::test_select_invalid_option_too_small tests/test_cmd2.py::test_select_list_of_strings tests/test_cmd2.py::test_select_list_of_tuples tests/test_cmd2.py::test_select_uneven_list_of_tuples tests/test_cmd2.py::test_select_eof tests/test_cmd2.py::test_select_ctrl_c tests/test_cmd2.py::test_read_input_rawinput_true tests/test_cmd2.py::test_read_input_rawinput_false tests/test_cmd2.py::test_read_command_line_eof
tests/test_cmd2.py::test_not_in_main_thread tests/test_cmd2.py::test_which_editor_good
tests/test_cmd2.py::test_version tests/test_cmd2.py::test_empty_statement tests/test_cmd2.py::test_base_help tests/test_cmd2.py::test_base_help_verbose tests/test_cmd2.py::test_base_argparse_help tests/test_cmd2.py::test_base_invalid_option tests/test_cmd2.py::test_base_shortcuts tests/test_cmd2.py::test_command_starts_with_shortcut tests/test_cmd2.py::test_base_show tests/test_cmd2.py::test_base_show_long tests/test_cmd2.py::test_base_show_readonly tests/test_cmd2.py::test_cast tests/test_cmd2.py::test_cast_problems tests/test_cmd2.py::test_base_set tests/test_cmd2.py::test_set_not_supported tests/test_cmd2.py::test_set_quiet tests/test_cmd2.py::test_set_allow_ansi[Never-False-Never] tests/test_cmd2.py::test_set_allow_ansi[neVeR-False-Never] tests/test_cmd2.py::test_set_allow_ansi[Terminal-False-Terminal] tests/test_cmd2.py::test_set_allow_ansi[TeRMInal-False-Terminal] tests/test_cmd2.py::test_set_allow_ansi[Always-False-Always] tests/test_cmd2.py::test_set_allow_ansi[AlWaYs-False-Always] tests/test_cmd2.py::test_set_allow_ansi[invalid-True-Terminal] tests/test_cmd2.py::test_set_onchange_hook tests/test_cmd2.py::test_base_shell tests/test_cmd2.py::test_base_py tests/test_cmd2.py::test_base_error tests/test_cmd2.py::test_run_script tests/test_cmd2.py::test_run_script_with_empty_args tests/test_cmd2.py::test_run_script_with_nonexistent_file tests/test_cmd2.py::test_run_script_with_directory tests/test_cmd2.py::test_run_script_with_empty_file tests/test_cmd2.py::test_run_script_with_binary_file tests/test_cmd2.py::test_run_script_with_python_file tests/test_cmd2.py::test_run_script_with_utf8_file tests/test_cmd2.py::test_run_script_nested_run_scripts tests/test_cmd2.py::test_runcmds_plus_hooks tests/test_cmd2.py::test_relative_run_script tests/test_cmd2.py::test_relative_run_script_with_odd_file_names tests/test_cmd2.py::test_relative_run_script_requires_an_argument tests/test_cmd2.py::test_in_script tests/test_cmd2.py::test_output_redirection tests/test_cmd2.py::test_output_redirection_to_nonexistent_directory tests/test_cmd2.py::test_output_redirection_to_too_long_filename tests/test_cmd2.py::test_feedback_to_output_true tests/test_cmd2.py::test_feedback_to_output_false tests/test_cmd2.py::test_disallow_redirection tests/test_cmd2.py::test_pipe_to_shell tests/test_cmd2.py::test_pipe_to_shell_and_redirect tests/test_cmd2.py::test_pipe_to_shell_error tests/test_cmd2.py::test_base_timing tests/test_cmd2.py::test_base_debug tests/test_cmd2.py::test_debug_not_settable tests/test_cmd2.py::test_edit_file tests/test_cmd2.py::test_edit_file_with_odd_file_names tests/test_cmd2.py::test_edit_file_with_spaces tests/test_cmd2.py::test_edit_blank tests/test_cmd2.py::test_base_py_interactive tests/test_cmd2.py::test_base_cmdloop_with_startup_commands tests/test_cmd2.py::test_base_cmdloop_without_startup_commands tests/test_cmd2.py::test_cmdloop_without_rawinput tests/test_cmd2.py::test_stty_sane tests/test_cmd2.py::test_precmd_hook_success tests/test_cmd2.py::test_precmd_hook_failure tests/test_cmd2.py::test_interrupt_quit tests/test_cmd2.py::test_interrupt_noquit tests/test_cmd2.py::test_default_to_shell tests/test_cmd2.py::test_ansi_prompt_not_esacped tests/test_cmd2.py::test_ansi_prompt_escaped tests/test_cmd2.py::test_custom_command_help tests/test_cmd2.py::test_custom_help_menu tests/test_cmd2.py::test_help_undocumented tests/test_cmd2.py::test_help_overridden_method tests/test_cmd2.py::test_help_cat_base tests/test_cmd2.py::test_help_cat_verbose tests/test_cmd2.py::test_help_with_no_docstring tests/test_cmd2.py::test_which_editor_bad tests/test_cmd2.py::test_multiline_complete_empty_statement_raises_exception tests/test_cmd2.py::test_multiline_complete_statement_without_terminator tests/test_cmd2.py::test_multiline_complete_statement_with_unclosed_quotes tests/test_cmd2.py::test_multiline_input_line_to_statement tests/test_cmd2.py::test_clipboard_failure tests/test_cmd2.py::test_commandresult_truthy tests/test_cmd2.py::test_commandresult_falsy tests/test_cmd2.py::test_is_text_file_bad_input tests/test_cmd2.py::test_eof tests/test_cmd2.py::test_echo tests/test_cmd2.py::test_poutput_string tests/test_cmd2.py::test_poutput_zero tests/test_cmd2.py::test_poutput_empty_string tests/test_cmd2.py::test_poutput_none tests/test_cmd2.py::test_poutput_ansi_always tests/test_cmd2.py::test_poutput_ansi_never tests/test_cmd2.py::test_get_alias_completion_items tests/test_cmd2.py::test_get_macro_completion_items tests/test_cmd2.py::test_get_settable_completion_items tests/test_cmd2.py::test_alias_no_subcommand tests/test_cmd2.py::test_alias_create tests/test_cmd2.py::test_alias_create_with_quoted_value tests/test_cmd2.py::test_alias_create_invalid_name[""] tests/test_cmd2.py::test_alias_create_invalid_name[#] tests/test_cmd2.py::test_alias_create_invalid_name[!no_shortcut] tests/test_cmd2.py::test_alias_create_invalid_name[">"] tests/test_cmd2.py::test_alias_create_invalid_name["no>pe"] tests/test_cmd2.py::test_alias_create_invalid_name["no tests/test_cmd2.py::test_alias_create_invalid_name["nopipe|"] tests/test_cmd2.py::test_alias_create_invalid_name["noterm;"] tests/test_cmd2.py::test_alias_create_invalid_name[noembedded"quotes] tests/test_cmd2.py::test_alias_create_with_command_name tests/test_cmd2.py::test_alias_create_with_macro_name tests/test_cmd2.py::test_alias_that_resolves_into_comment tests/test_cmd2.py::test_alias_list_invalid_alias tests/test_cmd2.py::test_alias_delete tests/test_cmd2.py::test_alias_delete_all tests/test_cmd2.py::test_alias_delete_non_existing tests/test_cmd2.py::test_alias_delete_no_name tests/test_cmd2.py::test_multiple_aliases tests/test_cmd2.py::test_macro_no_subcommand tests/test_cmd2.py::test_macro_create tests/test_cmd2.py::test_macro_create_with_quoted_value tests/test_cmd2.py::test_macro_create_invalid_name[""] tests/test_cmd2.py::test_macro_create_invalid_name[#] tests/test_cmd2.py::test_macro_create_invalid_name[!no_shortcut] tests/test_cmd2.py::test_macro_create_invalid_name[">"] tests/test_cmd2.py::test_macro_create_invalid_name["no>pe"] tests/test_cmd2.py::test_macro_create_invalid_name["no tests/test_cmd2.py::test_macro_create_invalid_name["nopipe|"] tests/test_cmd2.py::test_macro_create_invalid_name["noterm;"] tests/test_cmd2.py::test_macro_create_invalid_name[noembedded"quotes] tests/test_cmd2.py::test_macro_create_with_command_name tests/test_cmd2.py::test_macro_create_with_alias_name tests/test_cmd2.py::test_macro_create_with_args tests/test_cmd2.py::test_macro_create_with_escaped_args tests/test_cmd2.py::test_macro_usage_with_missing_args tests/test_cmd2.py::test_macro_usage_with_exta_args tests/test_cmd2.py::test_macro_create_with_missing_arg_nums tests/test_cmd2.py::test_macro_create_with_invalid_arg_num tests/test_cmd2.py::test_macro_create_with_unicode_numbered_arg tests/test_cmd2.py::test_macro_create_with_missing_unicode_arg_nums tests/test_cmd2.py::test_macro_that_resolves_into_comment tests/test_cmd2.py::test_macro_list_invalid_macro tests/test_cmd2.py::test_macro_delete tests/test_cmd2.py::test_macro_delete_all tests/test_cmd2.py::test_macro_delete_non_existing tests/test_cmd2.py::test_macro_delete_no_name tests/test_cmd2.py::test_multiple_macros tests/test_cmd2.py::test_nonexistent_macro tests/test_cmd2.py::test_ppaged tests/test_cmd2.py::test_ppaged_strips_ansi_when_redirecting tests/test_cmd2.py::test_ppaged_strips_ansi_when_redirecting_if_always tests/test_cmd2.py::test_parseline_empty tests/test_cmd2.py::test_parseline tests/test_cmd2.py::test_onecmd_raw_str_continue tests/test_cmd2.py::test_onecmd_raw_str_quit tests/test_cmd2.py::test_onecmd_add_to_history tests/test_cmd2.py::test_get_all_commands tests/test_cmd2.py::test_get_help_topics tests/test_cmd2.py::test_get_help_topics_hidden tests/test_cmd2.py::test_exit_code_default tests/test_cmd2.py::test_exit_code_nonzero tests/test_cmd2.py::test_ansi_pouterr_always_tty tests/test_cmd2.py::test_ansi_pouterr_always_notty tests/test_cmd2.py::test_ansi_terminal_tty tests/test_cmd2.py::test_ansi_terminal_notty tests/test_cmd2.py::test_ansi_never_tty tests/test_cmd2.py::test_ansi_never_notty tests/test_cmd2.py::test_disable_and_enable_category tests/test_cmd2.py::test_enable_enabled_command tests/test_cmd2.py::test_disable_fake_command tests/test_cmd2.py::test_disable_command_twice tests/test_cmd2.py::test_disabled_command_not_in_history tests/test_cmd2.py::test_disabled_message_command_name tests/test_cmd2.py::test_startup_script tests/test_cmd2.py::test_transcripts_at_init
cmd2/cmd2.py CHANGELOG.md cmd2/utils.py
cmd2/cmd2.py CHANGELOG.md cmd2/utils.py