Skip to content

Commit fdfac20

Browse files
committed
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.
1 parent 0f7a38f commit fdfac20

2 files changed

Lines changed: 25 additions & 7 deletions

File tree

‎docs/examples/embedding_in_textual.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ child process is the example script itself, run with `--cmd2-only --report-statu
4848

4949
```py
5050
def compose(self) -> ComposeResult:
51+
cmd2_command = [sys.executable, __file__, "--cmd2-only", "--report-status"]
5152
yield Header()
5253
with Horizontal():
5354
with Vertical(id="cmd2-pane"):
54-
yield Terminal(command=[sys.executable, __file__, "--cmd2-only", "--report-status"], id="cmd2")
55+
yield Terminal(command=cmd2_command, id="cmd2")
5556
with Vertical(id="sidebar"):
5657
yield StatusPanel(id="status")
5758
yield ClockPanel(id="clock")
@@ -105,6 +106,9 @@ The example handles several details which are easy to miss when building somethi
105106
Textual markup.
106107
- Only report status through the window title when running inside the Textual application. In a
107108
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.
108112
- When the child process exits, `Terminal.ProcessExited.exit_code` holds its exit code, which is the
109113
negated signal number if a signal killed it. The example passes the exit code back to the shell,
110114
using 128 plus the signal number like a shell does, and when it isn't 0 the example keeps the

‎examples/textual_tty_embed.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def __init__(self, report_status: bool) -> None:
6464

6565
self._report_status_enabled = report_status
6666
self._commands_run = 0
67+
self._running_command = ""
6768
self._last_command = ""
6869
self._last_duration = 0.0
6970
self._command_started = 0.0
@@ -82,13 +83,17 @@ def _report_status(self, state: str) -> None:
8283
return
8384
status = {
8485
"state": state,
86+
"running_command": self._running_command,
8587
"commands_run": self._commands_run,
8688
"last_command": self._last_command,
8789
"last_duration": self._last_duration,
8890
}
89-
self.set_window_title(STATUS_PREFIX + json.dumps(status))
90-
# prompt-toolkit buffers its output, so flush it or the "running" state would only appear after the command ends
91-
get_app_session().output.flush()
91+
# If the Textual application exits while a command is running, the terminal is closed before the finalization
92+
# hook reports the end of the command. Writing to it then fails, which doesn't matter because nobody can see it.
93+
with contextlib.suppress(OSError):
94+
self.set_window_title(STATUS_PREFIX + json.dumps(status))
95+
# prompt-toolkit buffers its output, so flush it or the "running" state would only appear after the command ends
96+
get_app_session().output.flush()
9297

9398
def preloop(self) -> None:
9499
"""Report the initial status before the first prompt."""
@@ -102,7 +107,7 @@ def _on_command_start(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.Po
102107
"""
103108
self._command_depth += 1
104109
if self._command_depth == 1:
105-
self._last_command = data.statement.command
110+
self._running_command = data.statement.command
106111
self._command_started = time.monotonic()
107112
self._report_status("running")
108113
return data
@@ -116,8 +121,12 @@ def _on_command_end(self, data: cmd2.plugin.CommandFinalizationData) -> cmd2.plu
116121
return data
117122
self._command_depth -= 1
118123
if self._command_depth == 0:
124+
# Only now does the running command become the last command, so it's never shown next to the duration
125+
# of the command before it
119126
self._commands_run += 1
127+
self._last_command = self._running_command
120128
self._last_duration = time.monotonic() - self._command_started
129+
self._running_command = ""
121130
self._report_status("idle")
122131
return data
123132

@@ -174,17 +183,19 @@ class StatusPanel(Static):
174183
"""The state of the embedded cmd2 application as last reported through its window title."""
175184

176185
state: reactive[str] = reactive("starting")
186+
running_command: reactive[str] = reactive("")
177187
commands_run: reactive[int] = reactive(0)
178188
last_command: reactive[str] = reactive("")
179189
last_duration: reactive[float] = reactive(0.0)
180190

181191
def render(self) -> str:
182192
"""Render the panel from the reactive attributes. Textual refreshes it when any of them change."""
183193
color = "green" if self.state == "idle" else "yellow"
194+
state = f"{self.state} {self.running_command}" if self.state == "running" else self.state
184195
# The strings come from the terminal, so escape them or something like "[/b]" would be read as markup
185196
return (
186197
"[b]cmd2 status[/b]\n"
187-
f"State: [{color}]{escape(self.state)}[/{color}]\n"
198+
f"State: [{color}]{escape(state)}[/{color}]\n"
188199
f"Commands run: {self.commands_run}\n"
189200
f"Last command: {escape(self.last_command) or '-'}\n"
190201
f"Last duration: {self.last_duration:.2f}s"
@@ -197,11 +208,13 @@ def apply(self, status: dict[str, Any]) -> None:
197208
"""
198209
# Convert every value before setting any of them, so a bad document doesn't half update the panel
199210
state = str(status.get("state", "unknown"))
211+
running_command = str(status.get("running_command", ""))
200212
commands_run = int(status.get("commands_run", 0))
201213
last_command = str(status.get("last_command", ""))
202214
last_duration = float(status.get("last_duration", 0.0))
203215

204216
self.state = state
217+
self.running_command = running_command
205218
self.commands_run = commands_run
206219
self.last_command = last_command
207220
self.last_duration = last_duration
@@ -241,12 +254,13 @@ def __init__(self) -> None:
241254

242255
def compose(self) -> ComposeResult:
243256
"""Create the layout: the terminal running cmd2 on the left and the status panels on the right."""
257+
cmd2_command = [sys.executable, __file__, "--cmd2-only", "--report-status"]
244258
yield Header()
245259
with Horizontal():
246260
# The border is on a container because a Terminal sizes its pseudo-terminal from its outer size, so a
247261
# border on the Terminal itself makes the cmd2 app draw more rows than are visible and hides its prompt
248262
with Vertical(id="cmd2-pane"):
249-
yield Terminal(command=[sys.executable, __file__, "--cmd2-only", "--report-status"], id="cmd2")
263+
yield Terminal(command=cmd2_command, id="cmd2")
250264
with Vertical(id="sidebar"):
251265
yield StatusPanel(id="status")
252266
yield ClockPanel(id="clock")

0 commit comments

Comments
 (0)