Skip to content

Add example embedding a cmd2 app in a Textual app with textual-tty - #1773

Open
DG-Elom wants to merge 4 commits into
python-cmd2:mainfrom
DG-Elom:textual-tty-example
Open

DG-Elom wants to merge 4 commits into
python-cmd2:mainfrom
DG-Elom:textual-tty-example

Conversation

@DG-Elom

@DG-Elom DG-Elom commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Adds examples/textual_tty_embed.py, showing how to embed a cmd2 app in a Textual app with textual-tty, with a realtime status panel next to the terminal.

  • The Textual app runs the cmd2 app (this same script with --cmd2-only) in a textual-tty Terminal, so the cmd2 app is a plain prompt-toolkit app that doesn't know it's embedded.
  • The cmd2 app reports its state (idle/running, commands run, last command and duration) with set_window_title() from precmd / command finalization hooks. The Terminal.TitleChanged message updates the panel, so no extra IPC is needed. A Textual-driven clock panel updates independently.
  • textual and textual-tty are not cmd2 dependencies. The module docstring shows how to run it: uv run --with textual-tty examples/textual_tty_embed.py.
  • Added to examples/README.md.

Tested headless with Textual's Pilot (textual 8.2.8, textual-tty 0.4.0, Linux): typing commands into the embedded terminal shows cmd2's output, and the status panel goes idle -> running -> idle with the command count updated. make test, make mypy, make docs-test and the prek hooks pass.

Note that textual-tty is still pre-alpha, so the example may need small updates if its API changes. Happy to adjust the approach (e.g. a socket instead of the window title) if you prefer.

Closes #1771

@codecov

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.75%. Comparing base (93cfe81) to head (a35a055).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1773   +/-   ##
=======================================
  Coverage   99.75%   99.75%           
=======================================
  Files          23       23           
  Lines        6473     6473           
=======================================
  Hits         6457     6457           
  Misses         16       16           
Flag Coverage Δ
unittests 99.75% <ø> (ø)

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.

@kmvanbrunt

Copy link
Copy Markdown
Member

Thanks for making this example. It works quite well, but I did run into one issue.

After running enough commands where the prompt should display at the bottom of the screen, it instead is hidden outside the CLI panel.

@DG-Elom

DG-Elom commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for catching this, I can reproduce it. The Terminal widget sizes its pseudo-terminal from its outer size, border included. With border: round on the Terminal itself, the PTY was 2 rows (and 2 columns) bigger than the visible area, so once the screen filled up prompt-toolkit drew the prompt on a row that was clipped. In a headless run at 100x30 the visible area was 26 rows, the PTY was 28, and the cursor ended on row 27.

I moved the border to a wrapping container in a35a055, so the PTY now matches the visible area (26 rows, cursor on row 25 after 40 commands).

@tleonhardt tleonhardt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DG-Elom I really love this example! Thank you for adding it and submitting this PR.

Please don't be discouraged by my thorough review. I found a number of small edge cases that can either cause a crash, display something that doesn't make sense, or result in behavior that doesn't seem to match the documentation.

"[b]cmd2 status[/b]\n"
f"State: [{color}]{self.state}[/{color}]\n"
f"Commands run: {self.commands_run}\n"
f"Last command: {self.last_command or '-'}\n"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command name is put into the StatusPanel's markup string without escaping. Typing [/b] or a[/x] at the prompt crashes the application with a MarkupError traceback and the textual app exits.

Fix the crash in one way or another. Easiest way is probably to wrap the command name in textual.markup.escape. Another alternative would be to catch the MarkupError and handle that case, but that is likely more work.

A different approach would be to only set self.last_commnad inside a cmd2 lifecycle hook that runs post command so it doesn't get set for invalid commands.

def _on_command_end(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plugin.CommandFinalizationData:
"""Report that the command is done, even when it failed."""
self._commands_run += 1
self._last_duration = time.monotonic() - self._command_started

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finalization hook also runs for blank lines, Ctrl-C, comments and parse errors, and precmd never runs for those. So it adds to the command count and computes the duration from a start time that is either old or 0.0.

Pressing Enter on a blank prompt as the first input showed a duration of about 590,000s (the system uptime on my laptop)

Should fix this so either it doesn't update the duration for invalid commands or that it always updates the start time even for unsuccessful commands.

"""Report the initial status before the first prompt."""
self._report_status("idle")

def _on_command_start(self, data: cmd2.plugin.PrecommandData) -> cmd2.plugin.PrecommandData:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commands called from run_script, history -r and py also fire the hooks. The panel flips to idle in the middle of the outer command, one typed command adds 4 to the count, and the last command shown is wrong.

Fix: keep a depth counter, or skip when in_script() or in_pyscript() is True.

return
try:
status = json.loads(event.title.removeprefix(STATUS_PREFIX))
except json.JSONDecodeError:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only JSONDecodeError is caught. A program in the pty that sets a title like cmd2-status:[], or one with the wrong value types, crashes the host.

Fix: check isinstance(status, dict) and also catch TypeError and ValueError.

# The border is on a container because a Terminal sizes its pseudo-terminal from its outer size, so a
# border on the Terminal itself makes the cmd2 app draw more rows than are visible and hides its prompt
with Vertical(id="cmd2-pane"):
yield Terminal(command=[sys.executable, __file__, "--cmd2-only"], id="cmd2")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bittty's WindowsPTY doesn't accept a list as the command, so on Windows the pane stays blank. The docstring says Windows works.

Fix: Either pass the executable and the command line separately on win32, or document the example as POSIX-only.

from typing import Any, ClassVar

from prompt_toolkit.application import get_app_session
from textual.app import App, ComposeResult

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a little off that --cmd2-only needs Textual.

textual is imported at the top of the module, so the "without Textual" mode fails with ModuleNotFoundError unless textual-tty is installed.

Moving the imports into the host-only code path fixes this.


def on_terminal_process_exited(self, _event: Terminal.ProcessExited) -> None:
"""Exit when the cmd2 application exits, for example due to the quit command."""
self.exit()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The child's exit code is never passed back, so the host always returns 0.

If the child crashes at startup, its traceback is wiped when the screen is restored.

Recommend passing back the child's exit code.

"last_command": self._last_command,
"last_duration": self._last_duration,
}
self.set_window_title(STATUS_PREFIX + json.dumps(status))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running --cmd2-only in a normal terminal (as opposed to say, a tmux terminal) overwrites the tab title with the status JSON and never restores it.

def do_slow(self, args: list[str]) -> None:
"""Run for a number of seconds (default 3) so the status panel can be seen in the running state."""
try:
seconds = float(args[0]) if args else 3.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slow -1, slow nan and slow inf print "Working…" and then raise an error from time.sleep.

Recommend validating prior to printing anything using something like:

import math
...
if seconds < 0 or not math.isfinite(seconds):
    self.perror(f"Argument must be a finite positive number: {args[0]}"
    return

"""

# Priority bindings are needed because the focused Terminal sends every key press to the cmd2 application
BINDINGS: ClassVar[list[BindingType]] = [Binding("ctrl+q", "quit", "Quit", priority=True)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Textual already has a priority binding for Ctrl-Q. The override only makes Quit show in the Footer, but the comment implies the binding has to be added.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create demo example app using textual-tty to embed a cmd2 app within a Textual app

3 participants