From 8e45c7f2824d20f113c5fb99ecc237287b4a894a Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 3 Nov 2026 07:56:03 +0100 Subject: [PATCH 1/3] feat: close the terminal interface when rendering is done The new option --exit-when-done closes the terminal interface as soon as rendering stops, for success and for failure. Without it the interface stays open until the user presses Enter. The render summary still goes to the terminal after the interface closes, so the user sees the outcome. Headless rendering is not affected, because it always ends by itself. --- docs/plain2code_cli.md | 7 ++- plain2code.py | 1 + plain2code_arguments.py | 10 ++++ tests/test_exit_when_done.py | 82 +++++++++++++++++++++++++++++++ tests/test_terminal_attachment.py | 1 + tui/plain2code_tui.py | 14 ++++++ 6 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/test_exit_when_done.py diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index dd10bed5..dbea7abe 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -19,7 +19,7 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path LOGGING_CONFIG_PATH] - [--headless] [--status] [--version] + [--headless] [--exit-when-done] [--status] [--version] [filename] Render plain code to target code. Path arguments resolve based on where they @@ -131,6 +131,11 @@ options: specification text itself stays in the log file only. Used automatically when stdin, stdout or stderr is not a terminal. + --exit-when-done Close the terminal interface as soon as rendering + stops, instead of keeping it open until you press + Enter. The render summary is printed to the terminal + as usual. Headless rendering always ends by itself, so + the option changes nothing there. --status Display account status including user information, API key label, and rendering credits. Does not render any code. diff --git a/plain2code.py b/plain2code.py index 93842b8d..17443da1 100644 --- a/plain2code.py +++ b/plain2code.py @@ -453,6 +453,7 @@ def run_render(): enter_pause_event=enter_pause_event, on_cancel=run_state.set_render_cancelled, default_log_level=default_log_level, + exit_when_done=args.exit_when_done, css_path="styles.css", ) app.run() diff --git a/plain2code_arguments.py b/plain2code_arguments.py index ff7b9355..8853ff22 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -511,6 +511,16 @@ def create_parser(): "Used automatically when stdin, stdout or stderr is not a terminal.", ) + _add_arg( + parser, + "--exit-when-done", + action="store_true", + default=False, + help="Close the terminal interface as soon as rendering stops, instead of keeping it open " + "until you press Enter. The render summary is printed to the terminal as usual. " + "Headless rendering always ends by itself, so the option changes nothing there.", + ) + parser.add_argument( "--status", action="store_true", diff --git a/tests/test_exit_when_done.py b/tests/test_exit_when_done.py new file mode 100644 index 00000000..fd3ce869 --- /dev/null +++ b/tests/test_exit_when_done.py @@ -0,0 +1,82 @@ +"""Tests for --exit-when-done: the TUI closes by itself once rendering has stopped.""" + +import asyncio +import threading + +from event_bus import EventBus +from plain2code_arguments import create_parser +from plain2code_events import RenderCompleted, RenderFailed +from plain2code_state import RunState +from tui.plain2code_tui import Plain2CodeTUI + + +def _make_app(run_state: RunState, event_bus: EventBus, exit_when_done: bool) -> Plain2CodeTUI: + return Plain2CodeTUI( + event_bus=event_bus, + run_state=run_state, + on_ready=lambda: None, + render_id="test-render-id", + unittests_script=None, + conformance_tests_script=None, + prepare_environment_script=None, + state_machine_version="0.0.0", + exit_when_done=exit_when_done, + css_path="styles.css", + ) + + +def _run(exit_when_done: bool, event) -> bool: + """Publish ``event`` to a mounted TUI and report whether the TUI closed itself.""" + + async def scenario() -> bool: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus, exit_when_done) + async with app.run_test() as pilot: + event_bus.publish(event) + await pilot.pause() + return app._exit + + return asyncio.run(scenario()) + + +def test_tui_closes_itself_when_rendering_completes(): + assert _run(True, RenderCompleted(rendered_code_path="plain_modules/x/")) is True + + +def test_tui_closes_itself_when_rendering_fails(): + assert _run(True, RenderFailed(error_message="Conformance tests failed")) is True + + +def test_tui_waits_for_the_user_without_the_option(): + assert _run(False, RenderCompleted(rendered_code_path="plain_modules/x/")) is False + assert _run(False, RenderFailed(error_message="Conformance tests failed")) is False + + +def test_tui_closes_itself_when_the_render_thread_reports_completion(): + """The render runs in its own thread, so the completion event reaches the TUI from there.""" + + async def scenario() -> bool: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus, True) + async with app.run_test() as pilot: + thread = threading.Thread( + target=lambda: event_bus.publish(RenderCompleted(rendered_code_path="plain_modules/x/")) + ) + thread.start() + thread.join() + await pilot.pause() + return app._exit + + assert asyncio.run(scenario()) is True + + +def test_option_is_off_by_default(): + args = create_parser().parse_args(["x.plain"]) + assert args.exit_when_done is False + + +def test_option_turns_on_from_the_command_line(): + args = create_parser().parse_args(["x.plain", "--exit-when-done"]) + assert args.exit_when_done is True diff --git a/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index 641f6bd0..f1bca60a 100644 --- a/tests/test_terminal_attachment.py +++ b/tests/test_terminal_attachment.py @@ -84,6 +84,7 @@ def make_args(): render_from=None, force_render=False, headless=False, + exit_when_done=False, replay_with=None, job_tag=None, log_to_file=False, diff --git a/tui/plain2code_tui.py b/tui/plain2code_tui.py index cb492d46..83edc188 100644 --- a/tui/plain2code_tui.py +++ b/tui/plain2code_tui.py @@ -84,6 +84,7 @@ def __init__( enter_pause_event: threading.Event | None = None, on_cancel: Callable[[], None] | None = None, default_log_level: str = "INFO", + exit_when_done: bool = False, **kwargs, ): super().__init__(**kwargs) @@ -104,6 +105,7 @@ def __init__( self.enter_pause_event = enter_pause_event self._on_cancel = on_cancel self.default_log_level = default_log_level + self.exit_when_done = exit_when_done self._render_finished = False # Initialize state handlers @@ -336,6 +338,7 @@ def on_render_completed(self, event: RenderCompleted): footer.update_footer_state("finished") except NoMatches: pass + self._exit_when_done() def on_render_failed(self, event: RenderFailed): """Handle render failure.""" @@ -347,6 +350,17 @@ def on_render_failed(self, event: RenderFailed): footer.update_footer_state("finished") except NoMatches: pass + self._exit_when_done() + + def _exit_when_done(self) -> None: + """Close the TUI once rendering has stopped, when --exit-when-done asked for it. + + Both render outcomes are reported on the terminal after the TUI closes, so nothing + the user needs is lost. The events arrive on the render thread; App.exit() posts the + message to the app thread by itself. + """ + if self.exit_when_done: + self.exit() async def action_copy_selection(self) -> None: """Handle ctrl+c: copy selected text if any. From 304596e9037351e40f9b70a918d39a56b47857b4 Mon Sep 17 00:00:00 2001 From: VitjanZ Date: Tue, 3 Nov 2026 10:57:36 +0100 Subject: [PATCH 2/3] fix: don't crash the TUI when --exit-when-done closes it mid-logging Log lines reach the TUI from the render thread and are drawn later via call_later(add_log). With --exit-when-done the TUI exited right after RenderCompleted/RenderFailed, so log entries still pending (or published after the final event) were mounted into the torn-down log view. That raised MountError and dumped a traceback on the terminal before the render summary. - StructuredLogView.add_log drops an entry once the view is detached, checked right before each mount. - The exit is queued with call_later, behind the log entries already scheduled, so those are still drawn before shutdown. - Regression test starts the render thread from on_ready, as plain2code.render() does, and publishes log lines around the final event; it fails without the fix. --- tests/test_exit_when_done.py | 46 +++++++++++++++++++++++++++++++++--- tui/components.py | 12 +++++++++- tui/plain2code_tui.py | 7 +++--- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/tests/test_exit_when_done.py b/tests/test_exit_when_done.py index fd3ce869..76724814 100644 --- a/tests/test_exit_when_done.py +++ b/tests/test_exit_when_done.py @@ -3,18 +3,20 @@ import asyncio import threading +import pytest + from event_bus import EventBus from plain2code_arguments import create_parser -from plain2code_events import RenderCompleted, RenderFailed +from plain2code_events import LogMessageEmitted, RenderCompleted, RenderFailed from plain2code_state import RunState from tui.plain2code_tui import Plain2CodeTUI -def _make_app(run_state: RunState, event_bus: EventBus, exit_when_done: bool) -> Plain2CodeTUI: +def _make_app(run_state: RunState, event_bus: EventBus, exit_when_done: bool, on_ready=lambda: None) -> Plain2CodeTUI: return Plain2CodeTUI( event_bus=event_bus, run_state=run_state, - on_ready=lambda: None, + on_ready=on_ready, render_id="test-render-id", unittests_script=None, conformance_tests_script=None, @@ -72,6 +74,44 @@ async def scenario() -> bool: assert asyncio.run(scenario()) is True +def _log(message: str) -> LogMessageEmitted: + return LogMessageEmitted(logger_name="codeplain", level="INFO", message=message, timestamp="00:00:01") + + +@pytest.mark.parametrize( + "event", [RenderCompleted(rendered_code_path="plain_modules/x/"), RenderFailed(error_message="x")] +) +def test_tui_closes_cleanly_while_log_lines_are_still_arriving(event): + """Log lines reach the TUI from the render thread right up to (and past) the final event. + + Entries still waiting to be drawn when the TUI closes must not crash it: before the fix, + mounting them into the torn-down log view raised MountError, which run_test re-raises. + """ + + async def scenario() -> bool: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + + def render(): + for i in range(20): + event_bus.publish(_log(f"step {i}")) + event_bus.publish(event) + for i in range(20): + event_bus.publish(_log(f"after {i}")) + + # Started from on_ready, exactly as plain2code.render() starts the render thread. + render_thread = threading.Thread(target=render, daemon=True) + app = _make_app(run_state, event_bus, True, on_ready=render_thread.start) + async with app.run_test() as pilot: + for _ in range(100): + if app._exit and not render_thread.is_alive(): + break + await pilot.pause(0.01) + return app._exit + + assert asyncio.run(scenario()) is True + + def test_option_is_off_by_default(): args = create_parser().parse_args(["x.plain"]) assert args.exit_when_done is False diff --git a/tui/components.py b/tui/components.py index 58c4ebaa..841d2241 100644 --- a/tui/components.py +++ b/tui/components.py @@ -624,12 +624,20 @@ def _should_show_log(self, level: str) -> bool: async def add_log( self, logger_name: str, level: str, message: str, timestamp: str = "", log_color: Optional[str] = None ): - """Add a new log entry.""" + """Add a new log entry. + + Log entries are scheduled from the render thread, so one can still be pending when the + TUI closes (e.g. with --exit-when-done). Once the view is detached from the DOM the entry + is dropped instead of mounted, since mounting into a detached widget raises MountError. + The check runs right before each mount because the view can detach while awaiting. + """ # Check if this is a success message that should have spacing before it is_success_message = any(keyword in message.lower() for keyword in LogEntry.SUCCESS_KEYWORDS) # Add empty line before success messages if is_success_message: + if not self.is_attached: + return spacer = Static("", classes="log-spacer") await self.mount(spacer) @@ -639,6 +647,8 @@ async def add_log( if not self._should_show_log(level): entry.display = False + if not self.is_attached: + return await self.mount(entry) # Auto-scroll to bottom to show latest logs self.scroll_end(animate=False) diff --git a/tui/plain2code_tui.py b/tui/plain2code_tui.py index 83edc188..02e77b61 100644 --- a/tui/plain2code_tui.py +++ b/tui/plain2code_tui.py @@ -356,11 +356,12 @@ def _exit_when_done(self) -> None: """Close the TUI once rendering has stopped, when --exit-when-done asked for it. Both render outcomes are reported on the terminal after the TUI closes, so nothing - the user needs is lost. The events arrive on the render thread; App.exit() posts the - message to the app thread by itself. + the user needs is lost. The events arrive on the render thread. The exit is queued + behind the log entries the render thread has already scheduled, so those are drawn + before the TUI starts shutting down; entries scheduled later are dropped by the log view. """ if self.exit_when_done: - self.exit() + self.call_later(self.exit) async def action_copy_selection(self) -> None: """Handle ctrl+c: copy selected text if any. From 8fd7b1a11365e8a886ffe11232b459e07cac35c6 Mon Sep 17 00:00:00 2001 From: VitjanZ Date: Tue, 3 Nov 2026 11:39:43 +0100 Subject: [PATCH 3/3] fix: address CodeRabbit review on --exit-when-done Show why the render failed after the TUI closes. A failure inside the render machine publishes RenderFailed without raising, so main() had no error message and the summary printed only "rendering failed". With --exit-when-done the TUI closes at once, so the reason ended up only in the log file. The TUI now keeps the reason on RunState and main() uses it as a fallback for the summary; a raised error still takes precedence. Headless mode is unchanged: the TUI handler never runs there, and the narration already prints the reason. Let the command line turn off an exit configured in config.yaml: --exit-when-done is now a BooleanOptionalAction, so --no-exit-when-done overrides "exit-when-done: true", like --no-log-to-file does. CLI docs regenerated. --- docs/plain2code_cli.md | 12 ++++++--- plain2code.py | 2 +- plain2code_arguments.py | 3 ++- plain2code_state.py | 5 ++++ tests/test_exit_when_done.py | 31 +++++++++++++++++++++- tests/test_render_finished_report.py | 39 ++++++++++++++++++++++++++-- tui/plain2code_tui.py | 3 +++ 7 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index dbea7abe..157c6af9 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -19,7 +19,8 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path LOGGING_CONFIG_PATH] - [--headless] [--exit-when-done] [--status] [--version] + [--headless] [--exit-when-done | --no-exit-when-done] + [--status] [--version] [filename] Render plain code to target code. Path arguments resolve based on where they @@ -131,11 +132,14 @@ options: specification text itself stays in the log file only. Used automatically when stdin, stdout or stderr is not a terminal. - --exit-when-done Close the terminal interface as soon as rendering + --exit-when-done, --no-exit-when-done + Close the terminal interface as soon as rendering stops, instead of keeping it open until you press Enter. The render summary is printed to the terminal - as usual. Headless rendering always ends by itself, so - the option changes nothing there. + as usual. Use --no-exit-when-done to keep the + interface open when config.yaml turns this on. + Headless rendering always ends by itself, so the + option changes nothing there. --status Display account status including user information, API key label, and rendering credits. Does not render any code. diff --git a/plain2code.py b/plain2code.py index 17443da1..126b354a 100644 --- a/plain2code.py +++ b/plain2code.py @@ -592,7 +592,7 @@ def main(): # noqa: C901 print_exit_summary( run_state, args.filename, - error_message=error_message, + error_message=error_message or run_state.render_error_message, ) # Remove any scratch extractions created for archive-only (".module") modules. for module in plain_module.all_required_modules + [plain_module]: diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 8853ff22..e9e783f2 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -514,10 +514,11 @@ def create_parser(): _add_arg( parser, "--exit-when-done", - action="store_true", + action=argparse.BooleanOptionalAction, default=False, help="Close the terminal interface as soon as rendering stops, instead of keeping it open " "until you press Enter. The render summary is printed to the terminal as usual. " + "Use --no-exit-when-done to keep the interface open when config.yaml turns this on. " "Headless rendering always ends by itself, so the option changes nothing there.", ) diff --git a/plain2code_state.py b/plain2code_state.py index a295cb97..8a49634b 100644 --- a/plain2code_state.py +++ b/plain2code_state.py @@ -12,6 +12,8 @@ def __init__(self, spec_filename: str, replay_with: Optional[str] = None, job_ta self.replay: bool = replay_with is not None self.render_succeeded: bool = False self.render_cancelled: bool = False + # Why the render failed, as the TUI showed it; printed in the summary after the TUI closes. + self.render_error_message: Optional[str] = None self.render_generated_code_path: Optional[str] = None self.rendered_functionalities: int = 0 if replay_with: @@ -39,6 +41,9 @@ def increment_unittest_batch_id(self): def set_render_succeeded(self, succeeded: bool): self.render_succeeded = succeeded + def set_render_error_message(self, error_message: str): + self.render_error_message = error_message + def set_render_cancelled(self): self.render_cancelled = True diff --git a/tests/test_exit_when_done.py b/tests/test_exit_when_done.py index 76724814..f331fcb5 100644 --- a/tests/test_exit_when_done.py +++ b/tests/test_exit_when_done.py @@ -6,7 +6,7 @@ import pytest from event_bus import EventBus -from plain2code_arguments import create_parser +from plain2code_arguments import create_parser, parse_arguments from plain2code_events import LogMessageEmitted, RenderCompleted, RenderFailed from plain2code_state import RunState from tui.plain2code_tui import Plain2CodeTUI @@ -50,6 +50,21 @@ def test_tui_closes_itself_when_rendering_fails(): assert _run(True, RenderFailed(error_message="Conformance tests failed")) is True +def test_tui_keeps_the_failure_reason_for_the_summary(): + """The TUI closes right away, so the reason it showed must survive on the run state.""" + + async def scenario() -> RunState: + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus, True) + async with app.run_test() as pilot: + event_bus.publish(RenderFailed(error_message="Conformance tests failed")) + await pilot.pause() + return run_state + + assert asyncio.run(scenario()).render_error_message == "Conformance tests failed" + + def test_tui_waits_for_the_user_without_the_option(): assert _run(False, RenderCompleted(rendered_code_path="plain_modules/x/")) is False assert _run(False, RenderFailed(error_message="Conformance tests failed")) is False @@ -120,3 +135,17 @@ def test_option_is_off_by_default(): def test_option_turns_on_from_the_command_line(): args = create_parser().parse_args(["x.plain", "--exit-when-done"]) assert args.exit_when_done is True + + +def test_option_turns_off_from_the_command_line(): + args = create_parser().parse_args(["x.plain", "--no-exit-when-done"]) + assert args.exit_when_done is False + + +def test_command_line_turns_off_an_exit_configured_in_config_yaml(tmp_path, monkeypatch): + (tmp_path / "x.plain").write_text("") + (tmp_path / "config.yaml").write_text("exit-when-done: true\n") + monkeypatch.chdir(tmp_path) + + assert parse_arguments([str(tmp_path / "x.plain")]).exit_when_done is True + assert parse_arguments([str(tmp_path / "x.plain"), "--no-exit-when-done"]).exit_when_done is False diff --git a/tests/test_render_finished_report.py b/tests/test_render_finished_report.py index 98baf38d..63c27a2b 100644 --- a/tests/test_render_finished_report.py +++ b/tests/test_render_finished_report.py @@ -108,7 +108,7 @@ def make_args(api_key="test-key", headless=False): ) -def run_main(render_side_effect=None, api_key="test-key"): +def run_main(render_side_effect=None, api_key="test-key", print_exit_summary=None): """Drive main() with everything but the reporting path stubbed out. render() is replaced, so the stub is what decides how the render "ended" - @@ -134,7 +134,7 @@ def fake_render(plain_module, codeplainAPI, args, run_state, event_bus, default_ patch("plain2code.setup_logging", return_value="INFO"), patch("plain2code.initialize_telemetry"), patch("plain2code.stop_narrating"), - patch("plain2code.print_exit_summary"), + patch("plain2code.print_exit_summary", print_exit_summary or MagicMock()), patch("plain2code.dump_crash_logs"), patch("plain2code.capture_crash"), patch("plain2code.codeplain_api.CodeplainAPI", return_value=api), @@ -204,3 +204,38 @@ def test_a_missing_api_key_reports_nothing(self): api = run_main(api_key=None) api.render_finished.assert_not_called() + + +class TestSummaryShowsWhyTheRenderFailed: + """A failure inside the render machine reaches main() only through the run state. + + The state machine publishes RenderFailed without raising, so render() returns normally + and main() has no error of its own. The TUI keeps the reason on the run state; with + --exit-when-done the summary is the only place the user still sees it. + """ + + def test_the_reason_the_tui_kept_is_printed(self): + def fail_inside_the_render_machine(run_state): + run_state.set_render_succeeded(False) + run_state.set_render_error_message("Conformance tests still fail after 20 attempts.") + + summary = MagicMock() + run_main(fail_inside_the_render_machine, print_exit_summary=summary) + + assert summary.call_args.kwargs["error_message"] == "Conformance tests still fail after 20 attempts." + + def test_a_raised_error_still_wins(self): + def fail(run_state): + run_state.set_render_error_message("reason the TUI kept") + raise Exception("rendering blew up") + + summary = MagicMock() + run_main(fail, print_exit_summary=summary) + + assert summary.call_args.kwargs["error_message"] == "rendering blew up" + + def test_a_successful_render_has_no_error_message(self): + summary = MagicMock() + run_main(lambda run_state: run_state.set_render_succeeded(True), print_exit_summary=summary) + + assert summary.call_args.kwargs["error_message"] is None diff --git a/tui/plain2code_tui.py b/tui/plain2code_tui.py index 02e77b61..dcd992a7 100644 --- a/tui/plain2code_tui.py +++ b/tui/plain2code_tui.py @@ -343,6 +343,9 @@ def on_render_completed(self, event: RenderCompleted): def on_render_failed(self, event: RenderFailed): """Handle render failure.""" self._render_error_handler.handle(event.error_message) + # A failure inside the render machine is not raised, so main() has no error message of + # its own; keep the reason so the summary printed after the TUI closes can show it. + self.run_state.set_render_error_message(event.error_message) self._render_finished = True self._finalize_usage_summary() try: