Skip to content

Commit e74ef91

Browse files
authored
Merge branch 'main' into fix/history-range-past-end
2 parents 18506b6 + e9c64db commit e74ef91

13 files changed

Lines changed: 512 additions & 9 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dist
1313

1414
# Code Coverage
1515
.coverage
16+
.coverage.*
1617
htmlcov
1718
junit
1819
coverage.xml

‎CHANGELOG.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@
2929
Other selectors are treated as numbers or ranges only when they match the supported syntax;
3030
strings such as URLs, `a:b`, `+1`, and `1_0` remain literal searches.
3131

32-
## 4.3.1 (TBD)
32+
## 4.3.1 (October 24, 2026)
3333

3434
- Bug Fixes
3535
- Fixed `@with_annotated` command `--help` output bypassing the application's output stream,
3636
leaving redirected help files empty and preventing help from being captured by embedded apps.
37+
- Enhancements
38+
- Added the
39+
[textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py)
40+
example, which embeds a `cmd2` application in a [Textual](https://textual.textualize.io/)
41+
application using [textual-tty](https://github.com/ttygroup/textual-tty). The Textual
42+
application shows a realtime status panel beside the `cmd2` application, which can display
43+
much richer information than a bottom toolbar. See the
44+
[Embedding in Textual](docs/examples/embedding_in_textual.md) documentation for details.
3745

3846
## 4.3.0 (October 13, 2026)
3947

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ completion, command history, scripting, output styling, and much more.
2626
You can use `cmd2` for a small internal tool and grow the same application into an extensible,
2727
scriptable interface without replacing the framework or writing the usual CLI boilerplate.
2828

29+
Want a richer visual display than a prompt and toolbar can offer? Your `cmd2` application can run
30+
inside a terminal pane of a [Textual](https://textual.textualize.io/) application, right next to
31+
dashboards and panels that show realtime status and other information. See
32+
[Embedding in Textual](https://cmd2.readthedocs.io/en/latest/examples/embedding_in_textual/) for an
33+
example.
34+
2935
## Quick start
3036

3137
Install `cmd2`:

‎cmd2/annotated.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3154,24 +3154,24 @@ def decorator(
31543154

31553155
@functools.wraps(command_func)
31563156
def cmd_wrapper(*args: Any, **kwargs: Any) -> _CommandReturn:
3157-
cmd2_app, statement_arg = _parse_positionals(args)
3157+
cmd_app, statement_arg = _parse_positionals(args)
31583158
owner = args[0] # Cmd or CommandSet instance
3159-
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
3159+
statement, parsed_arglist = cmd_app.statement_parser.get_command_arg_list(
31603160
command_name, statement_arg, preserve_quotes
31613161
)
31623162

3163-
arg_parser = cmd2_app.command_parsers.get(cast(BoundCommandFunc[...], cmd_wrapper))
3163+
arg_parser = cmd_app.command_parsers.get(cast(BoundCommandFunc[...], cmd_wrapper))
31643164
if arg_parser is None:
31653165
raise ValueError(f"No argument parser found for {command_name}")
31663166

31673167
if ns_provider is None:
31683168
namespace = None
31693169
else:
3170-
provider_self = cmd2_app._resolve_func_self(ns_provider, args[0])
3171-
namespace = ns_provider(provider_self if provider_self is not None else cmd2_app)
3170+
provider_self = cmd_app._resolve_func_self(ns_provider, args[0])
3171+
namespace = ns_provider(provider_self if provider_self is not None else cmd_app)
31723172

31733173
try:
3174-
with arg_parser.output_to(cmd2_app.stdout):
3174+
with arg_parser.output_to(cmd_app.stdout):
31753175
if with_unknown_args:
31763176
ns, unknown = arg_parser.parse_known_args(parsed_arglist, namespace)
31773177
else:
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Embedding in Textual
2+
3+
[Textual](https://textual.textualize.io/) builds full-screen terminal user interfaces with widgets,
4+
layouts, and mouse support, while `cmd2` builds interactive command interpreters. You don't have to
5+
choose between them. [textual-tty](https://github.com/ttygroup/textual-tty) provides a `Terminal`
6+
widget for Textual applications, which is a terminal emulator that runs a program attached to a
7+
pseudo-terminal. That program can be a `cmd2` application, so a Textual application can show a
8+
`cmd2` application in one pane and, beside it, other widgets which display realtime status and
9+
situational awareness information. This can be much richer than what fits in a
10+
[bottom toolbar](../features/prompt.md#bottom-toolbar).
11+
12+
The
13+
[textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py)
14+
example shows how to do this. It has a `cmd2` application in a terminal pane on the left. On the
15+
right, a status panel shows whether the `cmd2` application is idle or running a command, how many
16+
commands it has run, and how long the last one took, and a clock is updated by Textual on its own.
17+
18+
!!! warning
19+
20+
textual-tty is still pre-alpha software, so its API may change. The example was written for
21+
textual-tty 0.4. Also, on Windows textual-tty can only start a program by name and can't pass
22+
it arguments, so the example's Textual application requires a POSIX system such as Linux or
23+
macOS.
24+
25+
## Running the Example
26+
27+
Neither Textual nor textual-tty are dependencies of `cmd2`. In a `cmd2` checkout, textual-tty is
28+
part of the development environment on POSIX systems, so you can run:
29+
30+
```shell
31+
uv run examples/textual_tty_embed.py
32+
```
33+
34+
Otherwise, install textual-tty, which also installs Textual, into your own environment:
35+
36+
```shell
37+
pip install textual-tty
38+
python examples/textual_tty_embed.py
39+
```
40+
41+
Use the `quit` command or press ++ctrl+q++ to exit. Pass `--cmd2-only` to run just the `cmd2`
42+
application in your current terminal, without Textual.
43+
44+
## How It Works
45+
46+
The Textual application runs the `cmd2` application as a child process in a `Terminal` widget. The
47+
child process is the example script itself, run with `--cmd2-only --report-status`:
48+
49+
```py
50+
def compose(self) -> ComposeResult:
51+
cmd2_command = [sys.executable, __file__, "--cmd2-only", "--report-status"]
52+
yield Header()
53+
with Horizontal():
54+
with Vertical(id="cmd2-pane"):
55+
yield Terminal(command=cmd2_command, id="cmd2")
56+
with Vertical(id="sidebar"):
57+
yield StatusPanel(id="status")
58+
yield ClockPanel(id="clock")
59+
yield Footer()
60+
```
61+
62+
Because it is attached to a pseudo-terminal, the `cmd2` application is a completely normal `cmd2`
63+
application which uses `prompt-toolkit` and doesn't know that it is embedded. Everything typed into
64+
the focused `Terminal` goes to the `cmd2` application.
65+
66+
To report its state, the `cmd2` application registers a
67+
[postparsing hook](../features/hooks.md#postparsing-hooks), which runs before each command, and a
68+
[command finalization hook](../features/hooks.md#command-finalization-hooks), which runs after it.
69+
Both of them call [cmd2.Cmd.set_window_title][] with a JSON document. The document is prefixed with
70+
`cmd2-status:` so that the Textual application can tell it apart from other window titles. The
71+
`Terminal` widget turns each title change into a `Terminal.TitleChanged` message, which the Textual
72+
application handles to update the status panel:
73+
74+
```py
75+
def on_terminal_title_changed(self, event: Terminal.TitleChanged) -> None:
76+
if not event.title.startswith(STATUS_PREFIX):
77+
return
78+
with contextlib.suppress(TypeError, ValueError, OverflowError, RecursionError):
79+
status = json.loads(event.title.removeprefix(STATUS_PREFIX))
80+
if isinstance(status, dict):
81+
self.query_one(StatusPanel).apply(status)
82+
```
83+
84+
Since the status travels through the pseudo-terminal, there are no sockets, files, or threads to
85+
manage.
86+
87+
## Things to Watch Out For
88+
89+
The example handles several details which are easy to miss when building something similar:
90+
91+
- Command finalization hooks run for every line of input, including blank lines, comments,
92+
++ctrl+c++ at the prompt, and lines with syntax errors. The other hooks only run for lines which
93+
were parsed into a statement, so check whether `data.statement` is `None` in a finalization hook.
94+
- Commands run by other commands, like `run_script`, `history -r`, and `run_pyscript`, go through
95+
the hooks too. The example counts the running commands so that only the outermost one is reported.
96+
- The postparsing hooks stop running as soon as one of them sets `stop` or raises an exception, but
97+
the finalization hooks still run. If your application has other postparsing hooks, register the
98+
one which counts commands first so that it is never skipped.
99+
- `prompt-toolkit` buffers its output, so flush it after setting the window title when a command
100+
starts. Otherwise the "running" state only reaches the Textual application after the command is
101+
done.
102+
- Any program run in the terminal, for example by the `shell` command, can set the window title.
103+
Validate a status document before using it. Besides `json.JSONDecodeError`, `json.loads` raises
104+
`ValueError` for integers with too many digits and `RecursionError` for documents which are nested
105+
too deeply. Also escape the document's strings with `textual.markup.escape` before putting them in
106+
Textual markup.
107+
- Only report status through the window title when running inside the Textual application. In a
108+
normal terminal, it would overwrite the title of the user's window.
109+
- If the Textual application exits while a command is running, the pseudo-terminal is closed before
110+
the finalization hook reports the end of that command, so setting the window title raises
111+
`OSError`. The example ignores that error.
112+
- When the child process exits, `Terminal.ProcessExited.exit_code` holds its exit code, which is the
113+
negated signal number if a signal killed it. The example passes the exit code back to the shell,
114+
using 128 plus the signal number like a shell does, and when it isn't 0 the example keeps the
115+
terminal on the screen so that any traceback can still be read.
116+
- A `Terminal` sizes its pseudo-terminal from its outer size, so put a border on a container around
117+
the `Terminal` rather than on the `Terminal` itself. Otherwise the `cmd2` application draws more
118+
rows than are visible and its prompt can end up hidden.
119+
- The focused `Terminal` sends almost every key press to the `cmd2` application. Textual's built-in
120+
++ctrl+q++ binding to quit is a priority binding, so it still works.

‎docs/examples/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- [Getting Started](getting_started.md)
66
- [Alternate Event Loops](alternate_event_loops.md)
7+
- [Embedding in Textual](embedding_in_textual.md)
78
- [List of cmd2 examples](examples.md)
89

910
<!--intro-end-->

‎docs/features/prompt.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,10 @@ class App(cmd2.Cmd):
106106
See the
107107
[getting_started.py](https://github.com/python-cmd2/cmd2/blob/main/examples/getting_started.py)
108108
example for a demonstration of this technique.
109+
110+
!!! tip
111+
112+
If you want to show more realtime status information than fits in a toolbar, you can run your
113+
`cmd2` application inside a [Textual](https://textual.textualize.io/) application, next to
114+
widgets which display that information. See
115+
[Embedding in Textual](../examples/embedding_in_textual.md).

‎docs/overview/alternatives.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ If you are seeking a visually richer end-user experience and don't mind investin
3838
time, we would recommend checking out [Textual](https://github.com/Textualize/textual) as this can
3939
be used to build very sophisticated user interfaces in a terminal that are more akin to feature-rich
4040
web GUIs.
41+
42+
You don't have to choose between `cmd2` and Textual, though. The
43+
[textual-tty](https://github.com/ttygroup/textual-tty) package provides a terminal emulator widget
44+
for Textual, so a `cmd2` application can run inside a Textual application, next to widgets which
45+
display realtime information about it. See
46+
[Embedding in Textual](../examples/embedding_in_textual.md) for an example of how to do this.

‎examples/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ each:
9292
- Example of using Rich Tables within a cmd2 application for displaying tabular data
9393
- [rich_theme.py](https://github.com/python-cmd2/cmd2/blob/main/examples/rich_theme.py)
9494
- Demonstrates how to create a custom theme for a cmd2 application
95+
- [textual_tty_embed.py](https://github.com/python-cmd2/cmd2/blob/main/examples/textual_tty_embed.py)
96+
- Shows how to embed a `cmd2` application in a [Textual](https://textual.textualize.io/)
97+
application using [textual-tty](https://github.com/ttygroup/textual-tty), with a realtime
98+
status panel next to the terminal. Requires a POSIX system such as Linux or macOS and
99+
`textual-tty`, which is part of the `cmd2` development environment but not a `cmd2` dependency
95100
- [tmux_launch.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_launch.sh)
96101
- Shell script that launches two applications using tmux in different windows/tabs
97102
- [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh)

0 commit comments

Comments
 (0)