Skip to content

Commit c3296ab

Browse files
committed
feat: echo accepted prompt lines above the persistent toolbar
1 parent 3ed2831 commit c3296ab

2 files changed

Lines changed: 36 additions & 2 deletions

File tree

‎cmd2/command_toolbar.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from prompt_toolkit.application import Application, create_app_session
1717
from prompt_toolkit.enums import EditingMode
1818
from prompt_toolkit.filters import Condition, to_filter
19+
from prompt_toolkit.formatted_text import ANSI
1920
from prompt_toolkit.input.typeahead import get_typeahead, store_typeahead
2021
from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
2122
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
@@ -28,7 +29,6 @@
2829

2930
if TYPE_CHECKING:
3031
from prompt_toolkit.buffer import Buffer
31-
from prompt_toolkit.formatted_text import ANSI
3232

3333
from .cmd2 import Cmd
3434

@@ -425,7 +425,10 @@ def _accept_line(self, buff: "Buffer") -> bool:
425425
"""Hand an accepted line to the waiting command thread without ending the run."""
426426
if self._line is not None and not self._line.done():
427427
self._line.set_result(buff.document.text)
428-
return True # Keep the text; read_line() resets the buffer itself.
428+
# Discard the text so the live prompt region stops showing the accepted line.
429+
# _echo_accepted() is what commits it, and leaving it here too would draw it
430+
# twice. validate_and_handle() still records history before resetting.
431+
return False
429432

430433
def _fail_line(self, error: BaseException) -> None:
431434
"""Abort the pending read_line() with Ctrl-C's or Ctrl-D's exception."""
@@ -471,11 +474,28 @@ def enter() -> None:
471474
raise
472475
self._check_running()
473476
else:
477+
self._echo_accepted(message, text)
474478
return text
475479
finally:
476480
self._line = None
477481
session.default_buffer.accept_handler = self._session_accept
478482

483+
@staticmethod
484+
def _resolve_message(message: "PromptMessage") -> str:
485+
"""Render the prompt to text for the scrollback echo."""
486+
resolved = message() if callable(message) else message
487+
return resolved.value if isinstance(resolved, ANSI) else resolved
488+
489+
def _echo_accepted(self, message: "PromptMessage", text: str) -> None:
490+
"""Commit the accepted line above the toolbar.
491+
492+
prompt-toolkit normally leaves the prompt line in the scrollback when its run
493+
ends. Prompt mode never ends a run, so write the line through the same stdout
494+
proxy that keeps command output above the toolbar.
495+
"""
496+
self.cmd.stdout.write(f"{self._resolve_message(message)}{text}\n")
497+
self.cmd.stdout.flush()
498+
479499
def page(self, text: str, *, chop: bool) -> None:
480500
"""Show a pager above the same toolbar without starting another input reader."""
481501
size = self.app.output.get_size()

‎tests/test_command_toolbar.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,3 +842,17 @@ def test_read_line_ctrl_d_raises_without_stopping_the_app(toolbar_app) -> None:
842842
with pytest.raises(EOFError):
843843
toolbar.read_line(ANSI("> "))
844844
assert toolbar.is_active
845+
846+
847+
def test_read_line_echoes_the_accepted_line(toolbar_app) -> None:
848+
"""The accepted line is committed to the terminal exactly once.
849+
850+
prompt-toolkit's is_done frame used to do this. A continuous application never
851+
renders one, so read_line() has to write the line above the toolbar itself.
852+
"""
853+
app, pipe, output = toolbar_app
854+
with app._command_toolbar_context():
855+
toolbar = app._command_toolbar
856+
pipe.send_text("hello\n")
857+
assert toolbar.read_line(ANSI("myapp> ")) == "hello"
858+
assert output.getvalue().count("myapp> hello") == 1

0 commit comments

Comments
 (0)