Skip to content

Commit edac39e

Browse files
committed
feat: keep one application running across prompt and command execution
1 parent c3296ab commit edac39e

4 files changed

Lines changed: 91 additions & 23 deletions

File tree

‎cmd2/cmd2.py‎

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,15 @@ def _command_toolbar_context(self) -> Iterator[None]:
21332133
# one would disable the toolbar for the rest of the session.
21342134
self._command_toolbar = None
21352135

2136+
@contextlib.contextmanager
2137+
def _command_mode_context(self) -> Iterator[None]:
2138+
"""Show the command display while a command runs, if a toolbar is running."""
2139+
if self._command_toolbar is None:
2140+
yield
2141+
else:
2142+
with self._command_toolbar.command_mode():
2143+
yield
2144+
21362145
def get_rprompt(self) -> AnyFormattedText:
21372146
"""Provide text to populate the prompt-toolkit right prompt.
21382147
@@ -3880,6 +3889,11 @@ def _pre_prompt() -> None:
38803889
self._alert_condition.notify_all()
38813890

38823891
try:
3892+
# A running command display already owns the terminal and the application.
3893+
# Reading through it keeps that single run alive across the prompt, so the
3894+
# toolbar is never erased by an end-of-run frame.
3895+
if self._command_toolbar is not None and self._command_toolbar.is_active:
3896+
return self._command_toolbar.read_line(prompt_to_use, pre_run=_pre_prompt)
38833897
return self._read_raw_input(
38843898
prompt=prompt_to_use,
38853899
session=self.main_session,
@@ -3899,27 +3913,31 @@ def _cmdloop(self) -> None:
38993913
This serves the same role as cmd.cmdloop().
39003914
"""
39013915
try:
3902-
# Run startup commands
3903-
if self._startup_commands:
3904-
with self._command_toolbar_context():
3905-
stop = self.runcmds_plus_hooks(self._startup_commands)
3906-
else:
3907-
stop = False
3908-
self._startup_commands.clear()
3916+
# Hold one command display open for the whole loop. Starting and stopping
3917+
# it per command would end its application each time, and prompt-toolkit
3918+
# drops the bottom toolbar from the final frame of every run.
3919+
with self._command_toolbar_context():
3920+
# Run startup commands
3921+
if self._startup_commands:
3922+
with self._command_mode_context():
3923+
stop = self.runcmds_plus_hooks(self._startup_commands)
3924+
else:
3925+
stop = False
3926+
self._startup_commands.clear()
39093927

3910-
while not stop:
3911-
# Get commands from user
3912-
try:
3913-
line = self._read_command_line(self.prompt)
3914-
except KeyboardInterrupt:
3915-
self.poutput("^C")
3916-
line = ""
3917-
except EOFError:
3918-
line = "_eof"
3928+
while not stop:
3929+
# Get commands from user
3930+
try:
3931+
line = self._read_command_line(self.prompt)
3932+
except KeyboardInterrupt:
3933+
self.poutput("^C")
3934+
line = ""
3935+
except EOFError:
3936+
line = "_eof"
39193937

3920-
# Run the command along with all associated pre and post hooks
3921-
with self._command_toolbar_context():
3922-
stop = self.onecmd_plus_hooks(line)
3938+
# Run the command along with all associated pre and post hooks
3939+
with self._command_mode_context():
3940+
stop = self.onecmd_plus_hooks(line)
39233941
finally:
39243942
with self.sigint_protection:
39253943
# Shut down the alert thread.

‎cmd2/command_toolbar.py‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
if TYPE_CHECKING:
3131
from prompt_toolkit.buffer import Buffer
32+
from prompt_toolkit.key_binding.key_bindings import KeyBindingsBase
3233

3334
from .cmd2 import Cmd
3435

@@ -226,7 +227,10 @@ def prompt_eof(event: KeyPressEvent) -> None: # noqa: ARG001
226227
# display's set, so it is the PromptSession's own: completion, history, and
227228
# cmd2's bindings. Merging the overrides last lets them win, because
228229
# KeyProcessor calls matches[-1].
229-
self._prompt_bindings = merge_key_bindings([session.app.key_bindings, prompt_bindings])
230+
session_bindings = session.app.key_bindings
231+
self._prompt_bindings: KeyBindingsBase = (
232+
merge_key_bindings([session_bindings, prompt_bindings]) if session_bindings is not None else prompt_bindings
233+
)
230234
self._line: Future[str] | None = None
231235
# Only swapped in for the duration of read_line(). session.prompt() still runs
232236
# nested prompts and the no-toolbar fallback, and those rely on the stock
@@ -464,6 +468,14 @@ def enter() -> None:
464468
pre_run()
465469
self.app.invalidate()
466470

471+
# Keys typed while the command ran were swallowed by the save_key binding
472+
# so they could not become commands mid-execution. Replay them now that a
473+
# prompt is on screen and its bindings are active, in arrival order.
474+
if self._keys:
475+
pending, self._keys = self._keys, []
476+
self.app.key_processor.feed_multiple(pending)
477+
self.app.key_processor.process_keys()
478+
467479
try:
468480
self._call_in_ui(enter)
469481
while True:
@@ -483,7 +495,7 @@ def enter() -> None:
483495
@staticmethod
484496
def _resolve_message(message: "PromptMessage") -> str:
485497
"""Render the prompt to text for the scrollback echo."""
486-
resolved = message() if callable(message) else message
498+
resolved = message if isinstance(message, (ANSI, str)) else message()
487499
return resolved.value if isinstance(resolved, ANSI) else resolved
488500

489501
def _echo_accepted(self, message: "PromptMessage", text: str) -> None:
@@ -496,6 +508,25 @@ def _echo_accepted(self, message: "PromptMessage", text: str) -> None:
496508
self.cmd.stdout.write(f"{self._resolve_message(message)}{text}\n")
497509
self.cmd.stdout.flush()
498510

511+
@contextlib.contextmanager
512+
def command_mode(self) -> Iterator[None]:
513+
"""Swap to the toolbar-only layout while a command runs on the main thread."""
514+
515+
def enter() -> None:
516+
self.app.layout = self._layout
517+
self.app.key_bindings = self._bindings
518+
self.app.invalidate()
519+
520+
self._call_in_ui(enter)
521+
try:
522+
yield
523+
finally:
524+
# Restore the command display if a nested prompt or the pager swapped the
525+
# layout out. When the display has already stopped, _pause() has restored
526+
# the borrowed application's own state and there is nothing to put back.
527+
if self._thread is not None and self._thread.is_alive():
528+
self._call_in_ui(enter)
529+
499530
def page(self, text: str, *, chop: bool) -> None:
500531
"""Show a pager above the same toolbar without starting another input reader."""
501532
size = self.app.output.get_size()

‎tests/test_command_toolbar.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,3 +856,19 @@ def test_read_line_echoes_the_accepted_line(toolbar_app) -> None:
856856
pipe.send_text("hello\n")
857857
assert toolbar.read_line(ANSI("myapp> ")) == "hello"
858858
assert output.getvalue().count("myapp> hello") == 1
859+
860+
861+
def test_typeahead_during_a_command_reaches_the_next_prompt(toolbar_app) -> None:
862+
"""Keys typed while a command runs are handled by the prompt that follows it."""
863+
app, pipe, _ = toolbar_app
864+
865+
def slow(_) -> None:
866+
pipe.send_text("typed\n")
867+
time.sleep(0.2)
868+
869+
app.do_slow = slow
870+
with app._command_toolbar_context():
871+
toolbar = app._command_toolbar
872+
with app._command_mode_context():
873+
app.onecmd_plus_hooks("slow")
874+
assert toolbar.read_line(ANSI("> ")) == "typed"

‎tests/test_toolbar_flicker.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ def record(rendered_app) -> None:
3232
app.do_probe = lambda _: None
3333
try:
3434
pipe.send_text("probe\n")
35-
line = app._read_command_line(app.prompt)
35+
# Mirror _cmdloop: one display held open across both the prompt and the
36+
# command, rather than started and stopped around each command.
3637
with app._command_toolbar_context():
37-
app.onecmd_plus_hooks(line)
38+
line = app._read_command_line(app.prompt)
39+
with app._command_mode_context():
40+
app.onecmd_plus_hooks(line)
3841
finally:
3942
ui.after_render.remove_handler(record)
4043

0 commit comments

Comments
 (0)