Persistent bottom toolbar during command execution - #1744
Draft
tleonhardt wants to merge 8 commits into
Draft
Conversation
… and refreshing during command execution. Prompts, pagers, and shell commands temporarily suspend it while using the terminal. Try the work command examples/getting_started.py for a demonstration. The feature uses two prompt-toolkit displays at different times: the existing PromptSession while waiting for input, and a dedicated CommandToolbar while commands execute. - Command execution stays on the main thread. The command loop starts the toolbar’s UI in a background thread, using the same content callback, styling, and refresh interval. - Output appears above the toolbar. Stable stream wrappers route Python and piped subprocess output through prompt-toolkit’s output proxy. Their identities remain consistent across cmd2 redirection and toolbar suspension. - Input remains coordinated. The toolbar handles terminal position reports, saves typed-ahead keys for the next prompt, and forwards Ctrl-C to the main thread. - Other terminal interfaces get exclusive access. Nested prompts, pagers, and shell commands temporarily suspend the toolbar. Custom commands can do the same with suspend_bottom_toolbar(). - Cleanup restores normal terminal operation. When execution ends, buffered output is flushed, workers are stopped, and the original streams are restored. Most supporting code lives in cmd2/command_toolbar.py, with lifecycle integration in cmd2/cmd2.py. Because get_bottom_toolbar() runs in the UI thread during commands, shared mutable state should be protected with a lock.
1. Pipes to interactive targets (cmd2.py:3366, command_toolbar.py:43) — the pipe process now inherits the real terminal via the new pipe_target() helper, and the toolbar suspends for the life of the pipe (held in RedirectionSavedState.toolbar_suspension, released in _restore_output). Streams with no file descriptor still fall back to PIPE. Verified under a real pty: alias | python3 -c "...isatty()" printed PIPE_ISATTY True; before the fix that command produced no output at all. 2. Ctrl-C not reaching subprocesses (command_toolbar.py:161) — POSIX now uses os.killpg(os.getpgrp(), SIGINT), matching what the terminal driver does. Verified under a pty with a child that reports its own SIGINT: post-fix CHILD_GOT_SIGINT appears and the child exits immediately; pre-fix it never fired and the child ran to completion. Windows keeps interrupt_main() — broadcasting a console control event there would hit unrelated processes on the same console. Ctrl-\ stays inert, which matches the main prompt's existing behavior; documented rather than changed. 3. Failing stop() disabling the toolbar (cmd2.py:2098) — _command_toolbar = None moved into its own finally. 4. Writes racing _pause() (command_toolbar.py) — an RLock shared by the toolbar and its ToolbarStreams serializes writes against the proxy swap. The lock is deliberately released before _thread.join(), since the toolbar thread can itself be blocked writing through a stream. 5. Post-startup failures swallowed — new _app_exited() detaches the streams when the display stops on its own, so output goes to the terminal instead of a dead proxy, and reports the error. Worth noting: the review's stated trigger doesn't hold — a get_bottom_toolbar() that raises on refresh is caught by asyncio's default handler and the thread survives. The fix targets the case where the thread genuinely dies. 6. Final flush cancelled before writing — does not reproduce. Application.run_async already awaits wait_for_cpr_responses() and any in-flight run_in_terminal futures before its loop closes (application.py:770-779). I built the exact scenario — an output with responds_to_cpr=True and a CPR future confirmed pending at pause time — and the trailing text arrived with and without the proposed fix. I reverted my drain implementation, which was adding a run_in_terminal round-trip and up to a second of CPR waiting at every command boundary for nothing. I kept the test as a regression guard and a two-line guard against Application.exit() raising in a loop callback if the app has already stopped. 7. Docs/example mismatch — do_work now uses Annotated[int, Argument(nargs="?")], so work and work 5 both work. Verified in the real app.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1744 +/- ##
==========================================
+ Coverage 99.64% 99.66% +0.01%
==========================================
Files 23 24 +1
Lines 5973 6211 +238
==========================================
+ Hits 5952 6190 +238
Misses 21 21
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
Three tests called _read_raw_input() after the toolbar context had exited, so no prompt-toolkit app session was active. patch_stdout() then asked the default app session for its output, which lazily builds a real terminal Output: harmless Vt100_Output on POSIX, but Win32Output on Windows, which raises NoConsoleScreenBufferError without a console screen buffer of the kind GitHub Actions does not provide. Bind the fixture's app session to the test pipe and recording output so every prompt-toolkit call in these tests resolves to the test terminal rather than the ambient one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7nkx8kKC2mpaMGfLwXe6J
Closing the terminal's input ends the display's Application.run() with EOFError, which _resume() swallows because it is an ordinary shutdown rather than a failure the running command should hear about. Nothing exercised that handler, leaving it as the one uncovered line in the new module. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7nkx8kKC2mpaMGfLwXe6J
Member
Author
|
This branch and PR was a first attempt at making the bottom bar more persistent. It solved some but not all of the problems. A better approach came after in PR #1745 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Until now,
enable_bottom_toolbar=Truegave you a toolbar that existed only whileprompt-toolkitowned the terminal — it vanished the moment you pressed Enter and came back when the next prompt was drawn. This PR makes the toolbar persistent: it stays pinned to the bottom of the terminal and keeps refreshing while commands run, with command output scrolling above it.No API change is required to get this. Applications that already set
enable_bottom_toolbar=Truepick up the new behavior automatically.How it works
A new internal module,
cmd2/command_toolbar.py, runs a minimalprompt_toolkit.Applicationon a background thread while the command executes on the main thread:self.stdout,sys.stdout,sys.stderr) are wrapped in aToolbarStreamthat routes writes throughprompt-toolkit's stdout proxy. Only streams that are TTYs are wrapped, so redirected output and nested command redirection to a file are untouched.Ctrl-Cbehaves like it does at the prompt. Raw mode clearsISIG, so the toolbar forwardsSIGINTto the foreground process group (_thread.interrupt_main()on Windows), which reaches a subprocess a command is waiting on.Ctrl-Calso flushes pending typeahead so a cancelled keystroke never becomes the next command.Ctrl-Zstill suspends where supported andself.enable_suspendis set; the toolbar restores cooked mode before stopping and redraws on resume.Yielding the terminal
Anything that needs exclusive terminal access suspends the toolbar and restores it afterward. A
@suspend_toolbardecorator handles cmd2's own cases:ppaged(),select(),_read_raw_input(),do_shell(),_run_python(),do_ipy(), and_run_cmdfinalization_hooks().Piped output is handled specially: a pipe process may be interactive (
less,fzf), so it now inherits the real terminal file descriptor rather than having its output captured and relayed. The toolbar suspension is held open inRedirectionSavedState.toolbar_suspensionuntil_restore_output()reaps the process.For application code, a new public context manager covers custom terminal UIs, bare
input()calls, and subprocesses your commands start:Scope
The command toolbar is started by the interactive command loop only — including startup commands and scripts launched from it. It is disabled for non-interactive input, and direct
onecmd_plus_hooks()calls made outside the command loop do not start one. If the toolbar fails to start or dies mid-command, the error is reported viaperror(), streams are restored, and the reference is cleared so the rest of the session isn't left without a toolbar.Notes for toolbar authors
get_bottom_toolbar()now runs on a background UI thread during command execution. Keep it fast and guard any state a command or another thread mutates with a lock. Settingrefresh_interval > 0makes the toolbar update on a timer during command execution too, not just at the prompt.Docs and example
docs/features/prompt.mdrewritten for the new behavior, including a section on commands that take over the terminal and theCtrl-C/Ctrl-\semantics under raw mode.examples/getting_started.pygains awork [seconds]command that prints output on a one-second cadence so you can watch the toolbar clock tick alongside it.suspend_bottom_toolbaradded to the mkdocs API filter list; CHANGELOG entry opened under4.3.0 (TBD).Testing
tests/test_command_toolbar.pyadds 26 tests (470 lines) built on a pseudo-terminal harness, covering output ordering, suspension and nesting, typeahead capture,Ctrl-Cforwarding, redirection, and pipe handoff.Full suite on macOS: 1882 passed, 2 skipped (both skips are pre-existing Windows-only tests in
test_cmd2.py, unrelated to this change). Coverage of the new module is 100% (195/195 statements);cmd2/cmd2.pyis at 99%. The WindowsSIGINTpath is not exercised by this run and would benefit from a check on Windows CI.NOTES
This is one architectural approach to solving the problem. I marked it as draft because I am also working on a very different approach as well. Once both approaches are posted I will want other developers to review both approaches and we can decide which we like best from both a user experience and maintainability perspective.
TODO
Closes #1743