Skip to content

Persistent bottom toolbar during command execution - #1744

Draft
tleonhardt wants to merge 8 commits into
mainfrom
persistent-bottom-bar
Draft

Persistent bottom toolbar during command execution#1744
tleonhardt wants to merge 8 commits into
mainfrom
persistent-bottom-bar

Conversation

@tleonhardt

@tleonhardt tleonhardt commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

Until now, enable_bottom_toolbar=True gave you a toolbar that existed only while prompt-toolkit owned 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=True pick up the new behavior automatically.

How it works

A new internal module, cmd2/command_toolbar.py, runs a minimal prompt_toolkit.Application on a background thread while the command executes on the main thread:

  • Output goes above the toolbar. Terminal streams (self.stdout, sys.stdout, sys.stderr) are wrapped in a ToolbarStream that routes writes through prompt-toolkit's stdout proxy. Only streams that are TTYs are wrapped, so redirected output and nested command redirection to a file are untouched.
  • The display owns terminal input so it can receive cursor position reports. Keys typed during a command are stored as typeahead and replayed at the next prompt, matching normal terminal behavior.
  • Ctrl-C behaves like it does at the prompt. Raw mode clears ISIG, so the toolbar forwards SIGINT to the foreground process group (_thread.interrupt_main() on Windows), which reaches a subprocess a command is waiting on. Ctrl-C also flushes pending typeahead so a cancelled keystroke never becomes the next command.
  • Ctrl-Z still suspends where supported and self.enable_suspend is 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_toolbar decorator 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 in RedirectionSavedState.toolbar_suspension until _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:

with self.suspend_bottom_toolbar():
    answer = input("Continue? ")

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 via perror(), 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. Setting refresh_interval > 0 makes the toolbar update on a timer during command execution too, not just at the prompt.

Docs and example

  • docs/features/prompt.md rewritten for the new behavior, including a section on commands that take over the terminal and the Ctrl-C / Ctrl-\ semantics under raw mode.
  • examples/getting_started.py gains a work [seconds] command that prints output on a one-second cadence so you can watch the toolbar clock tick alongside it.
  • suspend_bottom_toolbar added to the mkdocs API filter list; CHANGELOG entry opened under 4.3.0 (TBD).

Testing

tests/test_command_toolbar.py adds 26 tests (470 lines) built on a pseudo-terminal harness, covering output ordering, suspension and nesting, typeahead capture, Ctrl-C forwarding, 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.py is at 99%. The Windows SIGINT path 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

  • Fix on Windows
  • Fully cover all new code with tests
  • Improve documentation

Closes #1743

… 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.
@tleonhardt
tleonhardt requested a review from bambu September 5, 2026 22:36
@tleonhardt tleonhardt self-assigned this Sep 5, 2026
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.66%. Comparing base (ca0ddbe) to head (063deed).

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              
Flag Coverage Δ
unittests 99.66% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

tleonhardt and others added 2 commits September 5, 2026 18:48
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
@tleonhardt

Copy link
Copy Markdown
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bottom bar should persist during command execution

1 participant