From b5b940bc8f20168f89bad3246fb6e1a999790c7f Mon Sep 17 00:00:00 2001 From: DG-Elom Date: Wed, 14 Oct 2026 12:22:47 +0200 Subject: [PATCH 01/11] Add example embedding a cmd2 app in a Textual app with textual-tty --- examples/README.md | 4 + examples/textual_tty_embed.py | 220 ++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100755 examples/textual_tty_embed.py diff --git a/examples/README.md b/examples/README.md index 3c85b03df..2b5b9a811 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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) diff --git a/examples/textual_tty_embed.py b/examples/textual_tty_embed.py new file mode 100755 index 000000000..61b0bfd94 --- /dev/null +++ b/examples/textual_tty_embed.py @@ -0,0 +1,220 @@ +#!/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 + +from prompt_toolkit.application import get_app_session +from textual.app import App, ComposeResult +from textual.binding import Binding +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)) + # 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: + """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 + 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 + 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" + 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 { + width: 2fr; + border: round $primary; + } + #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 = [Binding("ctrl+q", "quit", "Quit", priority=True)] + + 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(): + yield Terminal(command=[sys.executable, __file__, "--cmd2-only"], 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 + try: + status = json.loads(event.title.removeprefix(STATUS_PREFIX)) + except json.JSONDecodeError: + 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() + + +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() From 4b4632de16e0461a30ae67f21a1cf5720ab339c5 Mon Sep 17 00:00:00 2001 From: DG-Elom Date: Wed, 14 Oct 2026 13:26:25 +0200 Subject: [PATCH 02/11] Fix ruff RUF012 and ARG002 in textual-tty example --- examples/textual_tty_embed.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/textual_tty_embed.py b/examples/textual_tty_embed.py index 61b0bfd94..053c04a13 100755 --- a/examples/textual_tty_embed.py +++ b/examples/textual_tty_embed.py @@ -36,11 +36,11 @@ import sys import time from datetime import datetime -from typing import Any +from typing import Any, ClassVar from prompt_toolkit.application import get_app_session from textual.app import App, ComposeResult -from textual.binding import Binding +from textual.binding import Binding, BindingType from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.widgets import Footer, Header, Static @@ -178,7 +178,7 @@ class Cmd2InTextual(App[None]): """ # Priority bindings are needed because the focused Terminal sends every key press to the cmd2 application - BINDINGS = [Binding("ctrl+q", "quit", "Quit", priority=True)] + BINDINGS: ClassVar[list[BindingType]] = [Binding("ctrl+q", "quit", "Quit", priority=True)] def compose(self) -> ComposeResult: """Create the layout: the terminal running cmd2 on the left and the status panels on the right.""" @@ -204,7 +204,7 @@ def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None: return self.query_one(StatusPanel).apply(status) - def on_terminal_process_exited(self, event: Terminal.ProcessExited) -> None: + 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() From a35a0553dfeb183f92cabe29178f1ad2e573a836 Mon Sep 17 00:00:00 2001 From: DG-Elom Date: Wed, 14 Oct 2026 18:10:06 +0200 Subject: [PATCH 03/11] fix(examples): keep the embedded prompt visible in textual_tty_embed --- examples/textual_tty_embed.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/textual_tty_embed.py b/examples/textual_tty_embed.py index 053c04a13..728ede39d 100755 --- a/examples/textual_tty_embed.py +++ b/examples/textual_tty_embed.py @@ -163,10 +163,14 @@ class Cmd2InTextual(App[None]): TITLE = "cmd2 inside Textual" CSS = """ - #cmd2 { + #cmd2-pane { width: 2fr; border: round $primary; } + #cmd2 { + width: 1fr; + height: 1fr; + } #sidebar { width: 1fr; } @@ -184,7 +188,10 @@ 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(): - yield Terminal(command=[sys.executable, __file__, "--cmd2-only"], id="cmd2") + # 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") with Vertical(id="sidebar"): yield StatusPanel(id="status") yield ClockPanel(id="clock") From 784a96c6ccb5626db7eb2c7f88a2a65cafbe3a60 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:28:17 -0400 Subject: [PATCH 04/11] Fix edge cases in the textual_tty_embed example found in review - Escape the command name and state before putting them in the status panel's markup, so a command like "[/b]" no longer crashes the host. - Start tracking a command in a postparsing hook and ignore finalization for lines that never became a statement (blank lines, comments, Ctrl-C, syntax errors), so they aren't counted or timed from a stale start time. - Count running commands so ones run by run_script, history -r, and run_pyscript don't flip the panel to idle or inflate the command count. - Ignore status titles that aren't JSON objects or whose values have the wrong type, instead of crashing the host. - Document the Textual host as POSIX-only and refuse to start it on Windows, where bittty's PTY can't pass arguments to the child process. - Import Textual only in the host code path so --cmd2-only works without it. - Return the cmd2 app's exit code from the host, and keep the terminal on screen when the cmd2 app fails so its traceback can still be read. - Only report status through the window title with --report-status, which the host passes, so --cmd2-only doesn't overwrite a terminal's title. - Reject negative, NaN, infinite, and huge values in the slow command before printing anything. - Clarify that the Ctrl-Q binding is repeated only to show it in the footer. --- examples/README.md | 3 +- examples/textual_tty_embed.py | 334 +++++++++++++++++++++------------- 2 files changed, 210 insertions(+), 127 deletions(-) diff --git a/examples/README.md b/examples/README.md index 2b5b9a811..d822da952 100644 --- a/examples/README.md +++ b/examples/README.md @@ -95,7 +95,8 @@ each: - [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 + status panel next to the terminal. Requires `textual-tty`, which is not a `cmd2` dependency, + and a POSIX system such as Linux or macOS - [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 index 728ede39d..159e96464 100755 --- a/examples/textual_tty_embed.py +++ b/examples/textual_tty_embed.py @@ -9,10 +9,10 @@ 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. + - 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 precmd and command finalization hooks. The ``Terminal`` widget turns the title change + 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. @@ -21,10 +21,10 @@ 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). +``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: +The cmd2 application can also be run on its own, without Textual, on any platform: python examples/textual_tty_embed.py --cmd2-only @@ -39,12 +39,6 @@ from typing import Any, ClassVar from prompt_toolkit.application import get_app_session -from textual.app import App, ComposeResult -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 @@ -53,24 +47,35 @@ class StatusApp(cmd2.Cmd): - """A small cmd2 application which reports its state to whatever terminal it is running in.""" + """A small cmd2 application which can report 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.""" + 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> " - self.intro = "Run a few commands and watch the status panel next to this terminal." + 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._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 - self.register_precmd_hook(self._on_command_start) + 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, "commands_run": self._commands_run, @@ -85,18 +90,30 @@ 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: - """Report that a command is about to run.""" - self._last_command = data.statement.command or "" - self._command_started = time.monotonic() - self._report_status("running") + 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._last_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.""" - self._commands_run += 1 - self._last_duration = time.monotonic() - self._command_started - self._report_status("idle") + # 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. + if data.statement is None: + return data + self._command_depth -= 1 + if self._command_depth == 0: + self._commands_run += 1 + self._last_duration = time.monotonic() - self._command_started + self._report_status("idle") return data @cmd2.with_argument_list @@ -112,116 +129,181 @@ def do_slow(self, args: list[str]) -> None: 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.") -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" - 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)] - - 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") - 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: - 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() +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. -if __name__ == "__main__": + :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") + 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" + # 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(self.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")) + 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.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.""" + 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", "--report-status"], 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. + try: + status = json.loads(event.title.removeprefix(STATUS_PREFIX)) + except json.JSONDecodeError: + return + if not isinstance(status, dict): + return + try: + self.query_one(StatusPanel).apply(status) + except (TypeError, ValueError, OverflowError): + return + + def on_terminal_process_exited(self, event: Terminal.ProcessExited) -> None: + """Exit when the cmd2 application exits, for example due to the quit command.""" + self.cmd2_exit_code = event.exit_code + if event.exit_code == 0: + self.exit() + return + # Keep the terminal on the screen so the cmd2 application's error output, like a traceback, can be read + self.query_one(StatusPanel).state = f"exited with code {event.exit_code}" + self.notify(f"The cmd2 application exited with code {event.exit_code}", 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.cmd2_only: - sys.exit(StatusApp().cmdloop()) - else: - Cmd2InTextual().run() + 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()) From 0c31a92dba7f3a2266083e882f03cc10a9c4bd9c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:41:05 -0400 Subject: [PATCH 05/11] Add textual-tty to the dev dependency group Developers can now run the textual_tty_embed example out of the box with "uv run examples/textual_tty_embed.py". It is only a dev dependency, which CI doesn't install, and is limited to non-Windows platforms because the example's Textual host is POSIX-only and bittty pins pywinpty on Windows. --- examples/README.md | 4 ++-- examples/textual_tty_embed.py | 7 ++++--- pyproject.toml | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/README.md b/examples/README.md index d822da952..96a6291dc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -95,8 +95,8 @@ each: - [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, - and a POSIX system such as Linux or macOS + 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 index 159e96464..22986982e 100755 --- a/examples/textual_tty_embed.py +++ b/examples/textual_tty_embed.py @@ -16,11 +16,12 @@ 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: +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 --with textual-tty examples/textual_tty_embed.py + uv run examples/textual_tty_embed.py -or install the extra packages into your own environment with ``pip install textual-tty`` and then run +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. 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", From 4db0063e2cfe2469865fb0cefa580d6964b7771f Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:41:22 -0400 Subject: [PATCH 06/11] Document embedding a cmd2 app in a Textual app Add an "Embedding in Textual" page to the examples section of the docs which explains how the textual_tty_embed example works and the pitfalls it handles. Link to it from the alternatives overview and the bottom toolbar docs, and add a changelog entry for the example. --- CHANGELOG.md | 10 +++ docs/examples/embedding_in_textual.md | 114 ++++++++++++++++++++++++++ docs/examples/index.md | 1 + docs/features/prompt.md | 7 ++ docs/overview/alternatives.md | 6 ++ mkdocs.yml | 1 + 6 files changed, 139 insertions(+) create mode 100644 docs/examples/embedding_in_textual.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 21fa3a869..7c355dea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ - 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. + - Added `textual-tty` to the `dev` dependency group on POSIX systems so the example runs out of + the box in a development environment. It is not a runtime dependency of `cmd2`. ## 4.3.0 (October 13, 2026) diff --git a/docs/examples/embedding_in_textual.md b/docs/examples/embedding_in_textual.md new file mode 100644 index 000000000..8d3cf39b8 --- /dev/null +++ b/docs/examples/embedding_in_textual.md @@ -0,0 +1,114 @@ +# 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: + with Horizontal(): + with Vertical(id="cmd2-pane"): + yield Terminal(command=[sys.executable, __file__, "--cmd2-only", "--report-status"]) + with Vertical(id="sidebar"): + yield StatusPanel() + yield ClockPanel() +``` + +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 + try: + status = json.loads(event.title.removeprefix(STATUS_PREFIX)) + except json.JSONDecodeError: + return + if not isinstance(status, dict): + return + try: + self.query_one(StatusPanel).apply(status) + except (TypeError, ValueError, OverflowError): + return +``` + +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. +- `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, and escape its 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. +- When the child process exits, `Terminal.ProcessExited.exit_code` holds its exit code. The example + passes it back to the shell, 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/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 From c9c6960bcd03f28125502663659b627fbeefce80 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:51:28 -0400 Subject: [PATCH 07/11] Mention embedding in Textual in the README Add a short teaser to the README's introduction which links to the Embedding in Textual docs, so the capability is easy to discover. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) 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`: From c1fabfc2739ecccaa2ee5b4e8222792f4893f2b8 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:54:44 -0400 Subject: [PATCH 08/11] Removed entry in CHANGELOG that wasn't useful --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c355dea3..5e14c61c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,6 @@ 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. - - Added `textual-tty` to the `dev` dependency group on POSIX systems so the example runs out of - the box in a development environment. It is not a runtime dependency of `cmd2`. ## 4.3.0 (October 13, 2026) From 793e745f7ae02922a4fcd3120e31ab49b1524f87 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:06:05 -0400 Subject: [PATCH 09/11] Harden the textual_tty_embed example further - Ignore status titles which make json.loads raise ValueError (integers with too many digits) or RecursionError (documents nested too deeply), not just json.JSONDecodeError. - Report a cmd2 app killed by a signal as 128 + the signal number, like a shell does, instead of passing a negative exit code to sys.exit. - Never let the command depth go negative when a postparsing hook which ran first stopped the status hook from running, and document that the hook should be registered first. - Reject --report-status without --cmd2-only. - Limit textual-tty to <0.5 in the dev group since it is pre-alpha. - Make the compose() snippet in the docs match the example's code. --- docs/examples/embedding_in_textual.md | 36 +++++++++++++------------ examples/textual_tty_embed.py | 38 ++++++++++++++++----------- pyproject.toml | 2 +- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/docs/examples/embedding_in_textual.md b/docs/examples/embedding_in_textual.md index 8d3cf39b8..6c26c138b 100644 --- a/docs/examples/embedding_in_textual.md +++ b/docs/examples/embedding_in_textual.md @@ -48,12 +48,14 @@ child process is the example script itself, run with `--cmd2-only --report-statu ```py def compose(self) -> ComposeResult: + yield Header() with Horizontal(): with Vertical(id="cmd2-pane"): - yield Terminal(command=[sys.executable, __file__, "--cmd2-only", "--report-status"]) + yield Terminal(command=[sys.executable, __file__, "--cmd2-only", "--report-status"], id="cmd2") with Vertical(id="sidebar"): - yield StatusPanel() - yield ClockPanel() + 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` @@ -72,16 +74,10 @@ application handles to update the status panel: def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None: if not event.title.startswith(STATUS_PREFIX): return - try: + with contextlib.suppress(TypeError, ValueError, OverflowError, RecursionError): status = json.loads(event.title.removeprefix(STATUS_PREFIX)) - except json.JSONDecodeError: - return - if not isinstance(status, dict): - return - try: - self.query_one(StatusPanel).apply(status) - except (TypeError, ValueError, OverflowError): - return + 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 @@ -96,17 +92,23 @@ The example handles several details which are easy to miss when building somethi 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, and escape its strings with `textual.markup.escape` - before putting them in Textual markup. + 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. -- When the child process exits, `Terminal.ProcessExited.exit_code` holds its exit code. The example - passes it back to the shell, and when it isn't 0 the example keeps the terminal on the screen so - that any traceback can still be read. +- 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. diff --git a/examples/textual_tty_embed.py b/examples/textual_tty_embed.py index 22986982e..113710128 100755 --- a/examples/textual_tty_embed.py +++ b/examples/textual_tty_embed.py @@ -33,6 +33,7 @@ """ import argparse +import contextlib import json import sys import time @@ -70,6 +71,8 @@ def __init__(self, report_status: bool) -> None: # 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) @@ -107,8 +110,9 @@ def _on_command_start(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.Po 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. - if data.statement is None: + # 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: @@ -257,27 +261,29 @@ def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None: 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. - try: + # 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)) - except json.JSONDecodeError: - return - if not isinstance(status, dict): - return - try: - self.query_one(StatusPanel).apply(status) - except (TypeError, ValueError, OverflowError): - return + 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.""" - self.cmd2_exit_code = event.exit_code 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 = f"exited with code {event.exit_code}" - self.notify(f"The cmd2 application exited with code {event.exit_code}", severity="error") + 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.""" @@ -298,6 +304,8 @@ def main() -> int: 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() diff --git a/pyproject.toml b/pyproject.toml index 5b500b2a8..0e324a3f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dev = [ "pytest-mock>=3.14.1", "pytest-xdist>=3.8", "ruff>=0.16.0", - "textual-tty>=0.4.0 ; sys_platform != 'win32'", + "textual-tty>=0.4.0,<0.5 ; sys_platform != 'win32'", "ty>=0.0.75", "uv-publish>=1.3", "zensical>=0.0.57", From 0f7a38ff30fdb6068dc795e3c184247245164591 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:13:02 -0400 Subject: [PATCH 10/11] Allow textual-tty upgrades in dev dependencies --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0e324a3f4..5b500b2a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dev = [ "pytest-mock>=3.14.1", "pytest-xdist>=3.8", "ruff>=0.16.0", - "textual-tty>=0.4.0,<0.5 ; sys_platform != 'win32'", + "textual-tty>=0.4.0 ; sys_platform != 'win32'", "ty>=0.0.75", "uv-publish>=1.3", "zensical>=0.0.57", From fdfac202364f9daa82d32a39df48ba1601126bbd Mon Sep 17 00:00:00 2001 From: Todd Leonhardt <2812720+tleonhardt@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:37:19 -0400 Subject: [PATCH 11/11] Fix the textual_tty_embed status panel during and after commands - Report the running command separately and only update the last command when it finishes, so the panel no longer shows a running command's name next to the previous command's duration. The state line now reads, for example, "running slow". - Ignore OSError when setting the window title, which happens when the Textual application exits while a command is running and has already closed the terminal. - Keep the child's command line in a variable so the compose() snippet in the docs stays within the Markdown line length. --- docs/examples/embedding_in_textual.md | 6 +++++- examples/textual_tty_embed.py | 26 ++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/examples/embedding_in_textual.md b/docs/examples/embedding_in_textual.md index 6c26c138b..e7373bbc6 100644 --- a/docs/examples/embedding_in_textual.md +++ b/docs/examples/embedding_in_textual.md @@ -48,10 +48,11 @@ child process is the example script itself, run with `--cmd2-only --report-statu ```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=[sys.executable, __file__, "--cmd2-only", "--report-status"], id="cmd2") + yield Terminal(command=cmd2_command, id="cmd2") with Vertical(id="sidebar"): yield StatusPanel(id="status") yield ClockPanel(id="clock") @@ -105,6 +106,9 @@ The example handles several details which are easy to miss when building somethi 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 diff --git a/examples/textual_tty_embed.py b/examples/textual_tty_embed.py index 113710128..c93bdc5cb 100755 --- a/examples/textual_tty_embed.py +++ b/examples/textual_tty_embed.py @@ -64,6 +64,7 @@ def __init__(self, report_status: bool) -> None: 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 @@ -82,13 +83,17 @@ def _report_status(self, state: str) -> None: return status = { "state": state, + "running_command": self._running_command, "commands_run": self._commands_run, "last_command": self._last_command, "last_duration": self._last_duration, } - 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() + # 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.""" @@ -102,7 +107,7 @@ def _on_command_start(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.Po """ self._command_depth += 1 if self._command_depth == 1: - self._last_command = data.statement.command + self._running_command = data.statement.command self._command_started = time.monotonic() self._report_status("running") return data @@ -116,8 +121,12 @@ def _on_command_end(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plu 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 @@ -174,6 +183,7 @@ 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) @@ -181,10 +191,11 @@ class StatusPanel(Static): 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(self.state)}[/{color}]\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" @@ -197,11 +208,13 @@ def apply(self, status: dict[str, Any]) -> None: """ # 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 @@ -241,12 +254,13 @@ def __init__(self) -> None: 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=[sys.executable, __file__, "--cmd2-only", "--report-status"], id="cmd2") + yield Terminal(command=cmd2_command, id="cmd2") with Vertical(id="sidebar"): yield StatusPanel(id="status") yield ClockPanel(id="clock")