diff --git a/CHANGELOG.md b/CHANGELOG.md index 21fa3a869..5e14c61c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ - Bug Fixes - Fixed `@with_annotated` command `--help` output bypassing the application's output stream, leaving redirected help files empty and preventing help from being captured by embedded apps. +- Enhancements + - Added the + [textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py) + example, which embeds a `cmd2` application in a [Textual](https://textual.textualize.io/) + application using [textual-tty](https://github.com/ttygroup/textual-tty). The Textual + application shows a realtime status panel beside the `cmd2` application, which can display + much richer information than a bottom toolbar. See the + [Embedding in Textual](docs/examples/embedding_in_textual.md) documentation for details. ## 4.3.0 (October 13, 2026) diff --git a/README.md b/README.md index 81cd58b87..ce9464121 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ completion, command history, scripting, output styling, and much more. You can use `cmd2` for a small internal tool and grow the same application into an extensible, scriptable interface without replacing the framework or writing the usual CLI boilerplate. +Want a richer visual display than a prompt and toolbar can offer? Your `cmd2` application can run +inside a terminal pane of a [Textual](https://textual.textualize.io/) application, right next to +dashboards and panels that show realtime status and other information. See +[Embedding in Textual](https://cmd2.readthedocs.io/en/latest/examples/embedding_in_textual/) for an +example. + ## Quick start Install `cmd2`: diff --git a/docs/examples/embedding_in_textual.md b/docs/examples/embedding_in_textual.md new file mode 100644 index 000000000..e7373bbc6 --- /dev/null +++ b/docs/examples/embedding_in_textual.md @@ -0,0 +1,120 @@ +# Embedding in Textual + +[Textual](https://textual.textualize.io/) builds full-screen terminal user interfaces with widgets, +layouts, and mouse support, while `cmd2` builds interactive command interpreters. You don't have to +choose between them. [textual-tty](https://github.com/ttygroup/textual-tty) provides a `Terminal` +widget for Textual applications, which is a terminal emulator that runs a program attached to a +pseudo-terminal. That program can be a `cmd2` application, so a Textual application can show a +`cmd2` application in one pane and, beside it, other widgets which display realtime status and +situational awareness information. This can be much richer than what fits in a +[bottom toolbar](../features/prompt.md#bottom-toolbar). + +The +[textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py) +example shows how to do this. It has a `cmd2` application in a terminal pane on the left. On the +right, a status panel shows whether the `cmd2` application is idle or running a command, how many +commands it has run, and how long the last one took, and a clock is updated by Textual on its own. + +!!! warning + + textual-tty is still pre-alpha software, so its API may change. The example was written for + textual-tty 0.4. Also, on Windows textual-tty can only start a program by name and can't pass + it arguments, so the example's Textual application requires a POSIX system such as Linux or + macOS. + +## Running the Example + +Neither Textual nor textual-tty are dependencies of `cmd2`. In a `cmd2` checkout, textual-tty is +part of the development environment on POSIX systems, so you can run: + +```shell +uv run examples/textual_tty_embed.py +``` + +Otherwise, install textual-tty, which also installs Textual, into your own environment: + +```shell +pip install textual-tty +python examples/textual_tty_embed.py +``` + +Use the `quit` command or press ++ctrl+q++ to exit. Pass `--cmd2-only` to run just the `cmd2` +application in your current terminal, without Textual. + +## How It Works + +The Textual application runs the `cmd2` application as a child process in a `Terminal` widget. The +child process is the example script itself, run with `--cmd2-only --report-status`: + +```py +def compose(self) -> ComposeResult: + cmd2_command = [sys.executable, __file__, "--cmd2-only", "--report-status"] + yield Header() + with Horizontal(): + with Vertical(id="cmd2-pane"): + yield Terminal(command=cmd2_command, id="cmd2") + with Vertical(id="sidebar"): + yield StatusPanel(id="status") + yield ClockPanel(id="clock") + yield Footer() +``` + +Because it is attached to a pseudo-terminal, the `cmd2` application is a completely normal `cmd2` +application which uses `prompt-toolkit` and doesn't know that it is embedded. Everything typed into +the focused `Terminal` goes to the `cmd2` application. + +To report its state, the `cmd2` application registers a +[postparsing hook](../features/hooks.md#postparsing-hooks), which runs before each command, and a +[command finalization hook](../features/hooks.md#command-finalization-hooks), which runs after it. +Both of them call [cmd2.Cmd.set_window_title][] with a JSON document. The document is prefixed with +`cmd2-status:` so that the Textual application can tell it apart from other window titles. The +`Terminal` widget turns each title change into a `Terminal.TitleChanged` message, which the Textual +application handles to update the status panel: + +```py +def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None: + if not event.title.startswith(STATUS_PREFIX): + return + with contextlib.suppress(TypeError, ValueError, OverflowError, RecursionError): + status = json.loads(event.title.removeprefix(STATUS_PREFIX)) + if isinstance(status, dict): + self.query_one(StatusPanel).apply(status) +``` + +Since the status travels through the pseudo-terminal, there are no sockets, files, or threads to +manage. + +## Things to Watch Out For + +The example handles several details which are easy to miss when building something similar: + +- Command finalization hooks run for every line of input, including blank lines, comments, + ++ctrl+c++ at the prompt, and lines with syntax errors. The other hooks only run for lines which + were parsed into a statement, so check whether `data.statement` is `None` in a finalization hook. +- Commands run by other commands, like `run_script`, `history -r`, and `run_pyscript`, go through + the hooks too. The example counts the running commands so that only the outermost one is reported. +- The postparsing hooks stop running as soon as one of them sets `stop` or raises an exception, but + the finalization hooks still run. If your application has other postparsing hooks, register the + one which counts commands first so that it is never skipped. +- `prompt-toolkit` buffers its output, so flush it after setting the window title when a command + starts. Otherwise the "running" state only reaches the Textual application after the command is + done. +- Any program run in the terminal, for example by the `shell` command, can set the window title. + Validate a status document before using it. Besides `json.JSONDecodeError`, `json.loads` raises + `ValueError` for integers with too many digits and `RecursionError` for documents which are nested + too deeply. Also escape the document's strings with `textual.markup.escape` before putting them in + Textual markup. +- Only report status through the window title when running inside the Textual application. In a + normal terminal, it would overwrite the title of the user's window. +- If the Textual application exits while a command is running, the pseudo-terminal is closed before + the finalization hook reports the end of that command, so setting the window title raises + `OSError`. The example ignores that error. +- When the child process exits, `Terminal.ProcessExited.exit_code` holds its exit code, which is the + negated signal number if a signal killed it. The example passes the exit code back to the shell, + using 128 plus the signal number like a shell does, and when it isn't 0 the example keeps the + terminal on the screen so that any traceback can still be read. +- A `Terminal` sizes its pseudo-terminal from its outer size, so put a border on a container around + the `Terminal` rather than on the `Terminal` itself. Otherwise the `cmd2` application draws more + rows than are visible and its prompt can end up hidden. +- The focused `Terminal` sends almost every key press to the `cmd2` application. Textual's built-in + ++ctrl+q++ binding to quit is a priority binding, so it still works. diff --git a/docs/examples/index.md b/docs/examples/index.md index 6aad5a595..201cd1023 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -4,6 +4,7 @@ - [Getting Started](getting_started.md) - [Alternate Event Loops](alternate_event_loops.md) +- [Embedding in Textual](embedding_in_textual.md) - [List of cmd2 examples](examples.md) diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 0f8a09082..479ef9e55 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -106,3 +106,10 @@ class App(cmd2.Cmd): See the [getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py) example for a demonstration of this technique. + +!!! tip + + If you want to show more realtime status information than fits in a toolbar, you can run your + `cmd2` application inside a [Textual](https://textual.textualize.io/) application, next to + widgets which display that information. See + [Embedding in Textual](../examples/embedding_in_textual.md). diff --git a/docs/overview/alternatives.md b/docs/overview/alternatives.md index ec79594c5..5b13a9c2a 100644 --- a/docs/overview/alternatives.md +++ b/docs/overview/alternatives.md @@ -38,3 +38,9 @@ If you are seeking a visually richer end-user experience and don't mind investin time, we would recommend checking out [Textual](https://github.com/Textualize/textual) as this can be used to build very sophisticated user interfaces in a terminal that are more akin to feature-rich web GUIs. + +You don't have to choose between `cmd2` and Textual, though. The +[textual-tty](https://github.com/ttygroup/textual-tty) package provides a terminal emulator widget +for Textual, so a `cmd2` application can run inside a Textual application, next to widgets which +display realtime information about it. See +[Embedding in Textual](../examples/embedding_in_textual.md) for an example of how to do this. diff --git a/examples/README.md b/examples/README.md index 3c85b03df..96a6291dc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -92,6 +92,11 @@ 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 a POSIX system such as Linux or macOS and + `textual-tty`, which is part of the `cmd2` development environment but 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) diff --git a/examples/textual_tty_embed.py b/examples/textual_tty_embed.py new file mode 100755 index 000000000..c93bdc5cb --- /dev/null +++ b/examples/textual_tty_embed.py @@ -0,0 +1,332 @@ +#!/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 --report-status``, 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 postparsing 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. In a cmd2 checkout, textual-tty is part of the +development environment on POSIX systems, so run this example with: + + uv run examples/textual_tty_embed.py + +Otherwise, install textual-tty into your own environment with ``pip install textual-tty`` and then run +``python examples/textual_tty_embed.py``. The Textual application requires a POSIX system such as Linux or +macOS, because on Windows textual-tty can only start a program by name and can't pass it arguments. + +The cmd2 application can also be run on its own, without Textual, on any platform: + + 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 contextlib +import json +import sys +import time +from datetime import datetime +from typing import Any, ClassVar + +from prompt_toolkit.application import get_app_session + +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 can report its state to whatever terminal it is running in.""" + + def __init__(self, report_status: bool) -> None: + """Initialize the cmd2 application and register the hooks which track its status. + + :param report_status: if True, report the status through the terminal's window title. Only the Textual + application wants this. In a normal terminal it would overwrite the window title. + """ + super().__init__(allow_cli_args=False) + self.prompt = "status-demo> " + if report_status: + self.intro = "Run a few commands and watch the status panel next to this terminal." + + self._report_status_enabled = report_status + self._commands_run = 0 + self._running_command = "" + self._last_command = "" + self._last_duration = 0.0 + self._command_started = 0.0 + # Commands run by other commands, like run_script and history -r, go through the hooks too. This counts + # the commands which are running so that only the outermost one is reported. + self._command_depth = 0 + + # Register the postparsing hook before any others. The postparsing hooks stop running when one of them sets + # stop or raises an exception, but the finalization hooks still run, which would leave the depth unbalanced. + self.register_postparsing_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.""" + if not self._report_status_enabled: + return + status = { + "state": state, + "running_command": self._running_command, + "commands_run": self._commands_run, + "last_command": self._last_command, + "last_duration": self._last_duration, + } + # If the Textual application exits while a command is running, the terminal is closed before the finalization + # hook reports the end of the command. Writing to it then fails, which doesn't matter because nobody can see it. + with contextlib.suppress(OSError): + self.set_window_title(STATUS_PREFIX + json.dumps(status)) + # 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.PostparsingData) -> cmd2.plugin.PostparsingData: + """Report that a command is about to run. + + This is a postparsing hook rather than a precmd hook because the finalization hook runs for every statement + which reaches the postparsing hooks, while precmd hooks are skipped when, for example, redirection fails. + """ + self._command_depth += 1 + if self._command_depth == 1: + self._running_command = data.statement.command + 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.""" + # Finalization hooks also run for blank lines, comments, Ctrl-C at the prompt, and syntax errors. Those + # never became a statement, so the postparsing hook didn't run for them and there is nothing to report. Also + # never let the depth go negative, in case a postparsing hook which ran first stopped this hook from running. + if data.statement is None or self._command_depth == 0: + return data + self._command_depth -= 1 + if self._command_depth == 0: + # Only now does the running command become the last command, so it's never shown next to the duration + # of the command before it + self._commands_run += 1 + self._last_command = self._running_command + self._last_duration = time.monotonic() - self._command_started + self._running_command = "" + 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 + except ValueError: + self.perror(f"Not a number: {args[0]}") + return + # NaN fails every comparison, so this rejects it along with negative, infinite, and huge values + if not 0 <= seconds <= 60: + self.perror(f"Seconds must be from 0 to 60: {args[0]}") + return + self.poutput(f"Working for {seconds:g} seconds...") + time.sleep(seconds) + self.poutput("Done.") + + +def run_textual_app() -> int: + """Run the Textual application with the cmd2 application in a terminal pane and a status panel beside it. + + Textual is imported here rather than at the top of the module so the cmd2 application can run without it. + + :return: the exit code of the cmd2 application, or 0 if the Textual application was quit while it was running + """ + from textual.app import App, ComposeResult + from textual.binding import Binding, BindingType + from textual.containers import Horizontal, Vertical + from textual.markup import escape + from textual.reactive import reactive + from textual.widgets import Footer, Header, Static + from textual_tty import Terminal + + 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") + running_command: reactive[str] = reactive("") + 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" + state = f"{self.state} {self.running_command}" if self.state == "running" else self.state + # The strings come from the terminal, so escape them or something like "[/b]" would be read as markup + return ( + "[b]cmd2 status[/b]\n" + f"State: [{color}]{escape(state)}[/{color}]\n" + f"Commands run: {self.commands_run}\n" + f"Last command: {escape(self.last_command) or '-'}\n" + 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. + + :raises TypeError, ValueError, OverflowError: if a value can't be converted to the type it should have + """ + # Convert every value before setting any of them, so a bad document doesn't half update the panel + state = str(status.get("state", "unknown")) + running_command = str(status.get("running_command", "")) + commands_run = int(status.get("commands_run", 0)) + last_command = str(status.get("last_command", "")) + last_duration = float(status.get("last_duration", 0.0)) + + self.state = state + self.running_command = running_command + self.commands_run = commands_run + self.last_command = last_command + self.last_duration = last_duration + + 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; + } + """ + + # Textual already has a priority binding which makes Ctrl-Q quit even though the focused Terminal sends every + # other key press to the cmd2 application. It is hidden, so this repeats it only to show Quit in the footer. + BINDINGS: ClassVar[list[BindingType]] = [Binding("ctrl+q", "quit", "Quit", priority=True)] + + def __init__(self) -> None: + """Initialize the Textual application.""" + super().__init__() + self.cmd2_exit_code = 0 + + def compose(self) -> ComposeResult: + """Create the layout: the terminal running cmd2 on the left and the status panels on the right.""" + cmd2_command = [sys.executable, __file__, "--cmd2-only", "--report-status"] + 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=cmd2_command, id="cmd2") + 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 + # Any program run in the terminal, for example by the cmd2 shell command, can set the title. So ignore + # anything which isn't a valid status document rather than letting it crash the application. Besides + # json.JSONDecodeError, json.loads raises ValueError for integers with too many digits and RecursionError + # for documents which are nested too deeply. + with contextlib.suppress(TypeError, ValueError, OverflowError, RecursionError): + status = json.loads(event.title.removeprefix(STATUS_PREFIX)) + if isinstance(status, dict): + 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.""" + if event.exit_code == 0: + self.exit() + return + if event.exit_code < 0: + # The exit code of a process killed by a signal is the negated signal number. Shells report 128 + signal. + outcome = f"was killed by signal {-event.exit_code}" + self.cmd2_exit_code = 128 - event.exit_code + else: + outcome = f"exited with code {event.exit_code}" + self.cmd2_exit_code = event.exit_code + # Keep the terminal on the screen so the cmd2 application's error output, like a traceback, can be read + self.query_one(StatusPanel).state = outcome + self.notify(f"The cmd2 application {outcome}", severity="error") + + async def action_quit(self) -> None: + """Exit with the cmd2 application's exit code, so the shell can tell whether it failed.""" + self.exit(return_code=self.cmd2_exit_code) + + app = Cmd2InTextual() + app.run() + return app.return_code or 0 + + +def main() -> int: + """Run the Textual application, or just the cmd2 application if --cmd2-only is given.""" + 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") + parser.add_argument( + "--report-status", + action="store_true", + help="with --cmd2-only, report the status through the window title, which the Textual application reads", + ) + cli_args = parser.parse_args() + if cli_args.report_status and not cli_args.cmd2_only: + parser.error("--report-status can only be used with --cmd2-only") + + if cli_args.cmd2_only: + return StatusApp(report_status=cli_args.report_status).cmdloop() + if sys.platform == "win32": + parser.error("the Textual application requires a POSIX system, use --cmd2-only to run just the cmd2 application") + return run_textual_app() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mkdocs.yml b/mkdocs.yml index a3d1fd6d9..13631c2cb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -188,6 +188,7 @@ nav: - examples/index.md - examples/getting_started.md - examples/alternate_event_loops.md + - examples/embedding_in_textual.md - examples/examples.md - Mixins: - mixins/index.md diff --git a/pyproject.toml b/pyproject.toml index 89d258124..5b500b2a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dev = [ "pytest-mock>=3.14.1", "pytest-xdist>=3.8", "ruff>=0.16.0", + "textual-tty>=0.4.0 ; sys_platform != 'win32'", "ty>=0.0.75", "uv-publish>=1.3", "zensical>=0.0.57",