Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ each:
- Example of using Rich Tables within a cmd2 application for displaying tabular data
- [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py)
- Demonstrates how to create a custom theme for a cmd2 application
- [textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py)
- Shows how to embed a `cmd2` application in a [Textual](https://textual.textualize.io/)
application using [textual-tty](https://github.com/ttygroup/textual-tty), with a realtime
status panel next to the terminal. Requires `textual-tty`, which is not a `cmd2` dependency
- [tmux_launch.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_launch.sh)
- Shell script that launches two applications using tmux in different windows/tabs
- [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh)
Expand Down
227 changes: 227 additions & 0 deletions examples/textual_tty_embed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Embed a cmd2 application in a Textual application using textual-tty.

The Textual application shows the cmd2 application in a terminal pane on the left. A status panel on the
right is a regular Textual widget, so it can show much richer, realtime information than a bottom toolbar.
The panel has a wall clock which is updated by Textual and the state of the cmd2 application: whether it
is idle or running a command, how many commands it has run, and what the last one was.

How it works:
- [textual-tty](https://github.com/ttygroup/textual-tty) provides the ``Terminal`` widget, a terminal
emulator (built on bittty) which runs a child process attached to a pseudo-terminal.
- The child process is simply this script run with ``--cmd2-only``, so the cmd2 application is completely
unaware that it is being embedded. It is a normal cmd2 application which uses prompt-toolkit.
- To report its state to the host, the cmd2 application calls ``cmd2.Cmd.set_window_title()`` with a JSON
document from precmd and command finalization hooks. The ``Terminal`` widget turns the title change
into a ``Terminal.TitleChanged`` message which the Textual application uses to update the status panel.
This uses only the pseudo-terminal, so there are no sockets, files, or threads to manage.

Neither textual nor textual-tty are dependencies of cmd2. To run this example from a cmd2 checkout use:

uv run --with textual-tty examples/textual_tty_embed.py

or install the extra packages into your own environment with ``pip install textual-tty`` and then run
``python examples/textual_tty_embed.py``. This requires a POSIX terminal or Windows with pywinpty
(which textual-tty installs for you).

The cmd2 application can also be run on its own, without Textual:

python examples/textual_tty_embed.py --cmd2-only

Use the ``quit`` command in the embedded cmd2 application, or press Ctrl-Q, to exit.
"""

import argparse
import json
import sys
import time
from datetime import datetime
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.

from textual.binding import Binding, BindingType
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Footer, Header, Static
from textual_tty import Terminal

import cmd2

# Prefix which marks a window title as a status update from the cmd2 application
STATUS_PREFIX = "cmd2-status:"


class StatusApp(cmd2.Cmd):
"""A small cmd2 application which reports its state to whatever terminal it is running in."""

def __init__(self) -> None:
"""Initialize the cmd2 application and register the hooks which report its status."""
super().__init__(allow_cli_args=False)
self.prompt = "status-demo> "
self.intro = "Run a few commands and watch the status panel next to this terminal."

self._commands_run = 0
self._last_command = ""
self._last_duration = 0.0
self._command_started = 0.0

self.register_precmd_hook(self._on_command_start)
self.register_cmdfinalization_hook(self._on_command_end)

def _report_status(self, state: str) -> None:
"""Send the application's current state to the terminal as a window title."""
status = {
"state": state,
"commands_run": self._commands_run,
"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.

# prompt-toolkit buffers its output, so flush it or the "running" state would only appear after the command ends
get_app_session().output.flush()

def preloop(self) -> None:
"""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.

"""Report that a command is about to run."""
self._last_command = data.statement.command or ""
self._command_started = time.monotonic()
self._report_status("running")
return data

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.

self._report_status("idle")
return data

@cmd2.with_argument_list
def do_greet(self, args: list[str]) -> None:
"""Greet someone."""
self.poutput(f"Hello, {' '.join(args) or 'world'}!")

@cmd2.with_argument_list
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

except ValueError:
self.perror(f"Not a number: {args[0]}")
return
self.poutput(f"Working for {seconds:g} seconds...")
time.sleep(seconds)
self.poutput("Done.")


class ClockPanel(Static):
"""The time of day, which is updated by Textual and has nothing to do with the cmd2 application."""

def on_mount(self) -> None:
"""Start updating the clock every second."""
self.update_clock()
self.set_interval(1, self.update_clock)

def update_clock(self) -> None:
"""Show the current time."""
self.update(f"[b]Clock[/b]\n{datetime.now().astimezone():%H:%M:%S %Z}")


class StatusPanel(Static):
"""The state of the embedded cmd2 application as last reported through its window title."""

state: reactive[str] = reactive("starting")
commands_run: reactive[int] = reactive(0)
last_command: reactive[str] = reactive("")
last_duration: reactive[float] = reactive(0.0)

def render(self) -> str:
"""Render the panel from the reactive attributes. Textual refreshes it when any of them change."""
color = "green" if self.state == "idle" else "yellow"
return (
"[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.

f"Last duration: {self.last_duration:.2f}s"
)

def apply(self, status: dict[str, Any]) -> None:
"""Update the panel from a status document sent by the cmd2 application."""
self.state = str(status.get("state", "unknown"))
self.commands_run = int(status.get("commands_run", 0))
self.last_command = str(status.get("last_command", ""))
self.last_duration = float(status.get("last_duration", 0.0))


class Cmd2InTextual(App[None]):
"""A Textual application with a cmd2 application in a terminal pane and a status panel beside it."""

TITLE = "cmd2 inside Textual"

CSS = """
#cmd2-pane {
width: 2fr;
border: round $primary;
}
#cmd2 {
width: 1fr;
height: 1fr;
}
#sidebar {
width: 1fr;
}
#sidebar Static {
border: round $secondary;
padding: 0 1;
margin-bottom: 1;
}
"""

# 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.


def compose(self) -> ComposeResult:
"""Create the layout: the terminal running cmd2 on the left and the status panels on the right."""
yield Header()
with Horizontal():
# 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.

with Vertical(id="sidebar"):
yield StatusPanel(id="status")
yield ClockPanel(id="clock")
yield Footer()

def on_mount(self) -> None:
"""Give the keyboard focus to the terminal so typing goes to the cmd2 application."""
self.query_one(Terminal).focus()

def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None:
"""Update the status panel when the cmd2 application reports its status."""
if not event.title.startswith(STATUS_PREFIX):
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.

return
self.query_one(StatusPanel).apply(status)

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.



if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run a cmd2 application embedded in a Textual application.")
parser.add_argument("--cmd2-only", action="store_true", help="run just the cmd2 application, without Textual")
cli_args = parser.parse_args()

if cli_args.cmd2_only:
sys.exit(StatusApp().cmdloop())
else:
Cmd2InTextual().run()
Loading