From a493264f314787ec55363eebae1b824ceda851f6 Mon Sep 17 00:00:00 2001 From: Predrag Radenkovic Date: Sun, 19 Jul 2026 12:12:31 +0200 Subject: [PATCH] Communicate rendering credit usage in the TUI Add a live credit-usage line (functionalities / used credits / render time) beneath the render status, shown while rendering and frozen on success, failure, and cancellation. Wording matches the console post-render summary. - New root module usage_summary.py as the single source of truth for the usage line, shared by the console summary and the TUI. Kept at root so the TUI stays independent of cli_output/the renderer. - Elapsed render time is read from a single RunState.get_live_render_time() accessor, reused by the log timestamps (previously duplicated in ElapsedTimeFormatter and LoggingHandler), the TUI usage line, and the summary. add_to_render_time resets the segment start after banking so the value is not re-counted once a render completes. - The TUI owns only the refresh cadence: it reads get_live_render_time() and freezes the line while paused so it is never sampled inside the pause loop. On every terminal path it captures the live value onto run_state so the TUI line and console summary agree even when the render machine does not finalize its own time (fixes render time reporting 0 on failure and cancellation). - Fix format_duration_hms mangling whole-second inputs (10s rendered as "1s"); reuse it for the substate timers. - Reword the TUI success line to match the design and console ("rendering completed!", "generated code folder:"). --- cli_output/render_summary.py | 4 +- plain2code.py | 1 + plain2code_logger.py | 9 +- plain2code_state.py | 18 ++- plain2code_utils.py | 30 +++-- tests/test_plain2code_state.py | 36 ++++++ tests/test_plain2code_utils.py | 36 ++++++ tests/test_tui_usage.py | 209 +++++++++++++++++++++++++++++++++ tests/test_usage_summary.py | 28 +++++ tui/components.py | 13 +- tui/plain2code_tui.py | 72 +++++++++++- tui/styles.css | 4 + tui/widget_helpers.py | 21 +++- usage_summary.py | 30 +++++ 14 files changed, 476 insertions(+), 35 deletions(-) create mode 100644 tests/test_plain2code_state.py create mode 100644 tests/test_plain2code_utils.py create mode 100644 tests/test_tui_usage.py create mode 100644 tests/test_usage_summary.py create mode 100644 usage_summary.py diff --git a/cli_output/render_summary.py b/cli_output/render_summary.py index 51e9a706..6b62e33c 100644 --- a/cli_output/render_summary.py +++ b/cli_output/render_summary.py @@ -4,7 +4,7 @@ from plain2code_console import console from plain2code_state import RunState -from plain2code_utils import format_duration_hms +from usage_summary import format_usage_summary def print_exit_summary( @@ -24,7 +24,7 @@ def print_exit_summary( msg += f" [#8E8F91]render id:\t\t\t[#FFFFFF]{run_state.render_id}\n" msg += f" [#8E8F91]input file:\t\t\t[#FFFFFF]{spec_filename}\n" msg += f" [#8E8F91]generated code folder:\t[#FFFFFF]{run_state.render_generated_code_path or '-'}\n\n" - msg += f"[#8E8F91]functionalities [#FFFFFF]{run_state.rendered_functionalities} [#8E8F91]used credits [#FFFFFF]{run_state.rendered_functionalities} [#8E8F91]render time [#FFFFFF]{format_duration_hms(run_state.render_time_accumulated)}\n" + msg += format_usage_summary(run_state.rendered_functionalities, run_state.render_time_accumulated) + "\n" console.print(msg) if not run_state.render_succeeded and error_message: diff --git a/plain2code.py b/plain2code.py index 9b03c5f0..4a24085a 100644 --- a/plain2code.py +++ b/plain2code.py @@ -286,6 +286,7 @@ def run_render(): render_thread = threading.Thread(target=run_render, daemon=True) app = Plain2CodeTUI( event_bus=event_bus, + run_state=run_state, on_ready=render_thread.start, render_id=run_state.render_id, unittests_script=args.unittests_script, diff --git a/plain2code_logger.py b/plain2code_logger.py index 12f863b8..abb5f311 100644 --- a/plain2code_logger.py +++ b/plain2code_logger.py @@ -1,5 +1,4 @@ import logging -import time from event_bus import EventBus from plain2code_events import LogMessageEmitted @@ -42,9 +41,7 @@ def __init__(self, run_state: RunState, fmt: str = "%(elapsed_time)s %(levelname def format(self, record): # Calculate elapsed time the same way as LoggingHandler does for the TUI try: - offset_seconds = self.run_state.render_time_accumulated + int( - time.monotonic() - self.run_state.last_render_start_timestamp - ) + offset_seconds = self.run_state.get_live_render_time() except Exception: # If RunState is not available or there's any error, default to 00:00:00 offset_seconds = 0 @@ -74,9 +71,7 @@ def __init__(self, event_bus: EventBus, run_state: RunState): def emit(self, record): try: - offset_seconds = self.run_state.render_time_accumulated + int( - time.monotonic() - self.run_state.last_render_start_timestamp - ) + offset_seconds = self.run_state.get_live_render_time() hours = offset_seconds // 3600 minutes = (offset_seconds % 3600) // 60 diff --git a/plain2code_state.py b/plain2code_state.py index e508cf26..19cc21ac 100644 --- a/plain2code_state.py +++ b/plain2code_state.py @@ -48,7 +48,23 @@ def increment_rendered_functionalities(self): self.rendered_functionalities += 1 def add_to_render_time(self): - self.render_time_accumulated += int(time.monotonic() - self.last_render_start_timestamp) + now = time.monotonic() + self.render_time_accumulated += int(now - self.last_render_start_timestamp) + # Reset the segment start so get_live_render_time() does not re-count the + # span that was just banked (e.g. after a render completes). + self.last_render_start_timestamp = now + + def get_live_render_time(self) -> int: + """Render time so far in whole seconds, including the in-progress segment. + + This is the single source of truth for elapsed render time, shared by the + log timestamps, the TUI usage line, and the post-render summary. Pause time + is excluded because ``add_to_render_time`` banks the elapsed span at the + start of a pause and ``set_last_render_start_timestamp`` restarts the + segment on resume. The value is only transiently inflated while the code + sits inside the pause loop itself, which no consumer samples. + """ + return self.render_time_accumulated + int(time.monotonic() - self.last_render_start_timestamp) def set_last_render_start_timestamp(self): self.last_render_start_timestamp = time.monotonic() diff --git a/plain2code_utils.py b/plain2code_utils.py index 80dd0a8b..9425cf2c 100644 --- a/plain2code_utils.py +++ b/plain2code_utils.py @@ -16,19 +16,23 @@ def find_large_base64_blob(text: str) -> Optional[str]: return match.group(0) if match else None -def format_duration_hms(total_seconds: int) -> str: - """Format a duration in seconds as hours, minutes, and seconds (e.g. ``1h 2m 3.45s``, ``45.67s``).""" - if total_seconds < 0: - total_seconds = 0 - h = int(total_seconds // 3600) - m = int((total_seconds % 3600) // 60) - s = total_seconds % 60 - if h: - return f"{h}h {m}m {s}s" - if m: - return f"{m}m {s}s" - text = f"{s}".rstrip("0").rstrip(".") - return f"{text}s" if text else "0s" +def format_duration_hms(total_seconds: float) -> str: + """Format a whole-second duration compactly (e.g. ``10s``, ``5m 49s``, ``1h 2m``). + + Fractional seconds are truncated. Durations under a minute render as ``{s}s``, + under an hour as ``{m}m {s}s``, and beyond as ``{h}h {m}m``. + """ + elapsed = int(total_seconds) + if elapsed < 0: + elapsed = 0 + if elapsed < 60: + return f"{elapsed}s" + minutes = elapsed // 60 + seconds = elapsed % 60 + if minutes < 60: + return f"{minutes}m {seconds}s" + hours = minutes // 60 + return f"{hours}h {minutes % 60}m" AMBIGUITY_CAUSES = { diff --git a/tests/test_plain2code_state.py b/tests/test_plain2code_state.py new file mode 100644 index 00000000..71c1c714 --- /dev/null +++ b/tests/test_plain2code_state.py @@ -0,0 +1,36 @@ +"""Tests for RunState render-time accounting.""" + +import time + +from plain2code_state import RunState + + +def test_get_live_render_time_includes_in_progress_segment(): + run_state = RunState(spec_filename="x.plain") + run_state.render_time_accumulated = 100 + run_state.last_render_start_timestamp = time.monotonic() - 5 + # 100 banked + ~5 in the current segment. + assert run_state.get_live_render_time() == 105 + + +def test_add_to_render_time_banks_and_resets_segment(): + run_state = RunState(spec_filename="x.plain") + run_state.last_render_start_timestamp = time.monotonic() - 10 + + run_state.add_to_render_time() + + # The 10s segment is banked... + assert run_state.render_time_accumulated == 10 + # ...and the segment start is reset, so the value is not re-counted afterwards. + assert run_state.get_live_render_time() == 10 + + +def test_add_to_render_time_is_cumulative_across_segments(): + run_state = RunState(spec_filename="x.plain") + + run_state.last_render_start_timestamp = time.monotonic() - 3 + run_state.add_to_render_time() + run_state.last_render_start_timestamp = time.monotonic() - 4 + run_state.add_to_render_time() + + assert run_state.render_time_accumulated == 7 diff --git a/tests/test_plain2code_utils.py b/tests/test_plain2code_utils.py new file mode 100644 index 00000000..d56b3d74 --- /dev/null +++ b/tests/test_plain2code_utils.py @@ -0,0 +1,36 @@ +"""Tests for pure helpers in plain2code_utils.""" + +from plain2code_utils import format_duration_hms + + +class TestFormatDurationHms: + def test_zero_seconds(self): + assert format_duration_hms(0) == "0s" + + def test_sub_minute_seconds(self): + assert format_duration_hms(5) == "5s" + assert format_duration_hms(49) == "49s" + + def test_sub_minute_multiple_of_ten(self): + # Regression: trailing-zero seconds must not be stripped (10s, not 1s). + assert format_duration_hms(10) == "10s" + assert format_duration_hms(20) == "20s" + assert format_duration_hms(30) == "30s" + + def test_exact_minute(self): + assert format_duration_hms(60) == "1m 0s" + + def test_minutes_and_seconds(self): + assert format_duration_hms(90) == "1m 30s" + assert format_duration_hms(349) == "5m 49s" + + def test_hours_drop_seconds(self): + assert format_duration_hms(3600) == "1h 0m" + assert format_duration_hms(3661) == "1h 1m" + + def test_float_input_is_truncated(self): + assert format_duration_hms(10.9) == "10s" + assert format_duration_hms(349.4) == "5m 49s" + + def test_negative_input_clamped_to_zero(self): + assert format_duration_hms(-5) == "0s" diff --git a/tests/test_tui_usage.py b/tests/test_tui_usage.py new file mode 100644 index 00000000..08f4c64a --- /dev/null +++ b/tests/test_tui_usage.py @@ -0,0 +1,209 @@ +"""Headless TUI tests for the live credit-usage line and terminal summaries.""" + +import asyncio +import time + +from textual.widgets import Static + +from event_bus import EventBus +from plain2code_events import RenderCompleted, RenderFailed +from plain2code_state import RunState +from tui.components import TUIComponents +from tui.plain2code_tui import Plain2CodeTUI + + +def _make_app(run_state: RunState, event_bus: EventBus) -> 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", + css_path="styles.css", + ) + + +def _set_live_render_time(run_state: RunState, seconds: int) -> None: + """Make run_state.get_live_render_time() return ``seconds`` deterministically. + + Models an in-progress segment (nothing banked yet) that started ``seconds`` ago, + which is the state on the exception/cancel paths the render machine never + finalizes. + """ + run_state.render_time_accumulated = 0 + run_state.last_render_start_timestamp = time.monotonic() - seconds + + +def _usage_text(app: Plain2CodeTUI) -> str: + widget = app.query_one(f"#{TUIComponents.RENDER_USAGE_WIDGET.value}", Static) + return str(widget.content) + + +def _status_text(app: Plain2CodeTUI) -> str: + widget = app.query_one(f"#{TUIComponents.RENDER_STATUS_WIDGET.value}", Static) + return str(widget.content) + + +def test_usage_line_reflects_live_progress(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + async with app.run_test() as pilot: + run_state.rendered_functionalities = 3 + _set_live_render_time(run_state, 349) + app._refresh_usage_summary() + await pilot.pause() + + text = _usage_text(app) + assert "functionalities [#FFFFFF]3" in text + assert "used credits [#FFFFFF]3" in text + assert "render time [#FFFFFF]5m 49s" in text + + asyncio.run(scenario()) + + +def test_usage_line_frozen_while_paused(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + async with app.run_test(): + _set_live_render_time(run_state, 10) + app._refresh_usage_summary() + assert "render time [#FFFFFF]10s" in _usage_text(app) + + # While paused, a refresh must not sample the render time again. + app._usage_paused = True + _set_live_render_time(run_state, 999) # 16m 39s + app._refresh_usage_summary() + assert "render time [#FFFFFF]10s" in _usage_text(app) + assert "16m 39s" not in _usage_text(app) + + # Resuming samples again. + app._usage_paused = False + app._refresh_usage_summary() + assert "render time [#FFFFFF]16m 39s" in _usage_text(app) + + asyncio.run(scenario()) + + +def test_usage_line_finalized_on_success(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + async with app.run_test() as pilot: + run_state.rendered_functionalities = 6 + _set_live_render_time(run_state, 349) + event_bus.publish(RenderCompleted(rendered_code_path="plain_modules/hello_world_python/")) + await pilot.pause() + + status = _status_text(app) + assert "rendering completed!" in status + assert "generated code folder: plain_modules/hello_world_python/" in status + + usage = _usage_text(app) + assert "functionalities [#FFFFFF]6" in usage + assert "used credits [#FFFFFF]6" in usage + assert "render time [#FFFFFF]5m 49s" in usage + assert app._render_finished is True + # The live value is captured onto the run state so the console summary matches. + assert run_state.render_time_accumulated == 349 + + asyncio.run(scenario()) + + +def test_usage_line_below_error_on_failure(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + async with app.run_test() as pilot: + # Failure after some functionalities and elapsed time. The render machine + # did NOT finalize run_state.render_time_accumulated (exception failure + # path), so the shared live value must supply the real elapsed time. + run_state.rendered_functionalities = 2 + _set_live_render_time(run_state, 349) + + error = "Conformance tests failed for functionality 3" + event_bus.publish(RenderFailed(error_message=error)) + await pilot.pause() + + status = _status_text(app) + assert error in status + + usage = _usage_text(app) + assert "functionalities [#FFFFFF]2" in usage + assert "used credits [#FFFFFF]2" in usage + # Regression: this was "0s" because the summary trusted the unfinalized state. + assert "render time [#FFFFFF]5m 49s" in usage + assert app._render_finished is True + assert run_state.render_time_accumulated == 349 + + asyncio.run(scenario()) + + +def test_usage_line_zero_when_failure_before_any_progress(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + async with app.run_test() as pilot: + # Failure before any functionality/time (e.g. a syntax error at parse time). + _set_live_render_time(run_state, 0) + error = "Syntax error at line 1: Invalid specification heading (`implementation req`)" + event_bus.publish(RenderFailed(error_message=error)) + await pilot.pause() + + usage = _usage_text(app) + assert "functionalities [#FFFFFF]0" in usage + assert "used credits [#FFFFFF]0" in usage + assert "render time [#FFFFFF]0s" in usage + + asyncio.run(scenario()) + + +def test_usage_line_stops_updating_after_completion(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + async with app.run_test() as pilot: + run_state.rendered_functionalities = 2 + _set_live_render_time(run_state, 5) + event_bus.publish(RenderCompleted(rendered_code_path="plain_modules/x/")) + await pilot.pause() + + # Late mutation must not change the frozen final usage line. + run_state.rendered_functionalities = 99 + app._refresh_usage_summary() + await pilot.pause() + + usage = _usage_text(app) + assert "functionalities [#FFFFFF]2" in usage + assert "functionalities [#FFFFFF]99" not in usage + + asyncio.run(scenario()) + + +def test_cancel_records_render_time_for_summary(): + async def scenario(): + event_bus = EventBus() + run_state = RunState(spec_filename="x.plain") + app = _make_app(run_state, event_bus) + app._on_cancel = run_state.set_render_cancelled + async with app.run_test(): + # The render machine never finalizes render_time_accumulated on cancel, + # so the TUI must capture the live value for the summary. + run_state.rendered_functionalities = 2 + _set_live_render_time(run_state, 42) + app.action_quit() + + assert run_state.render_cancelled is True + assert run_state.render_time_accumulated == 42 + + asyncio.run(scenario()) diff --git a/tests/test_usage_summary.py b/tests/test_usage_summary.py new file mode 100644 index 00000000..6c8022a8 --- /dev/null +++ b/tests/test_usage_summary.py @@ -0,0 +1,28 @@ +"""Tests for the shared credit-usage summary line.""" + +from usage_summary import format_usage_summary + + +class TestFormatUsageSummary: + def test_used_credits_equals_functionalities(self): + line = format_usage_summary(6, 349) + assert "functionalities [#FFFFFF]6" in line + assert "used credits [#FFFFFF]6" in line + assert "render time [#FFFFFF]5m 49s" in line + + def test_zero_usage(self): + line = format_usage_summary(0, 0) + assert "functionalities [#FFFFFF]0" in line + assert "used credits [#FFFFFF]0" in line + assert "render time [#FFFFFF]0s" in line + + def test_labels_use_muted_color(self): + line = format_usage_summary(3, 10) + assert line.startswith("[#8E8F91]functionalities") + assert "[#8E8F91]used credits" in line + assert "[#8E8F91]render time" in line + + def test_custom_colors(self): + line = format_usage_summary(2, 5, label_color="#111111", value_color="#222222") + assert "[#111111]functionalities [#222222]2" in line + assert "[#222222]5s" in line diff --git a/tui/components.py b/tui/components.py index 761a64d7..45c8fb6c 100644 --- a/tui/components.py +++ b/tui/components.py @@ -6,6 +6,8 @@ from textual.timer import Timer from textual.widgets import Button, Static +from plain2code_utils import format_duration_hms + from .models import Substate from .spinner import Spinner @@ -137,6 +139,7 @@ def get_padded_label(self) -> str: class TUIComponents(str, Enum): RENDER_STATUS_WIDGET = "render-status-widget" + RENDER_USAGE_WIDGET = "render-usage-widget" # FRID Progress widgets FRID_PROGRESS = "frid-progress" @@ -195,15 +198,7 @@ def _add_second(self) -> None: self._refresh_timer() def _format_timer(self) -> str: - elapsed = int(self._seconds_elapsed) - if elapsed < 60: - return f"{elapsed}s" - minutes = elapsed // 60 - seconds = elapsed % 60 - if minutes < 60: - return f"{minutes}m {seconds}s" - hours = minutes // 60 - return f"{hours}h {minutes % 60}m" + return format_duration_hms(self._seconds_elapsed) def _format_line(self) -> str: timer = self._format_timer() diff --git a/tui/plain2code_tui.py b/tui/plain2code_tui.py index 3035aa14..1325cb8a 100644 --- a/tui/plain2code_tui.py +++ b/tui/plain2code_tui.py @@ -19,8 +19,16 @@ RenderPaused, RenderStateUpdated, ) +from plain2code_state import RunState from render_machine.states import States -from tui.widget_helpers import display_module_name, log_to_widget, stop_progress_timer, transition_frid_progress +from tui.widget_helpers import ( + display_module_name, + display_usage_summary, + log_to_widget, + stop_progress_timer, + transition_frid_progress, +) +from usage_summary import format_usage_summary from .components import ( CustomFooter, @@ -60,9 +68,12 @@ class Plain2CodeTUI(App): ("ctrl+l", "toggle_logs", "Toggle Logs"), ] + USAGE_REFRESH_INTERVAL_SECONDS = 1.0 + def __init__( self, event_bus: EventBus, + run_state: RunState, on_ready: Callable[[], None], render_id: str, unittests_script: str, @@ -77,6 +88,12 @@ def __init__( super().__init__(**kwargs) self.dark = True # Set dark mode as default self.event_bus = event_bus + self.run_state = run_state + # Live credit-usage line. The elapsed render time is read from the shared + # run_state.get_live_render_time(); the TUI only owns the refresh cadence and + # freezes it while paused so the line is never sampled inside the pause loop. + self._usage_timer = None + self._usage_paused = False self._on_ready = on_ready self.render_id = render_id self.unittests_script: Optional[str] = unittests_script @@ -124,6 +141,9 @@ def on_mount(self) -> None: self.event_bus.subscribe(LogMessageEmitted, self.on_log_message_emitted) self.event_bus.subscribe(RenderPaused, self.on_render_paused) + # Live credit-usage line: refresh functionalities / used credits / render time each second. + self._usage_timer = self.set_interval(self.USAGE_REFRESH_INTERVAL_SECONDS, self._refresh_usage_summary) + if self.default_log_level != "INFO": try: log_widget = self.query_one(f"#{TUIComponents.LOG_WIDGET.value}", StructuredLogView) @@ -167,12 +187,50 @@ def compose(self) -> ComposeResult: "[#FFFFFF]Rendering in progress...[/#FFFFFF]", id=TUIComponents.RENDER_STATUS_WIDGET.value, ) + yield Static( + format_usage_summary(0, 0), + id=TUIComponents.RENDER_USAGE_WIDGET.value, + ) with Vertical(id=TUIComponents.LOG_VIEW.value): yield LogLevelFilter(id=TUIComponents.LOG_FILTER.value) yield Static("", classes="filter-spacer") yield StructuredLogView(id=TUIComponents.LOG_WIDGET.value) yield CustomFooter(render_id=self.render_id) + def _refresh_usage_summary(self) -> None: + """Refresh the live credit-usage line while the render is in progress. + + Runs on the main (event-loop) thread via the interval timer. While paused + the line is left untouched so it is never sampled inside the pause loop. + Once the render has finished it stops the timer from here — cancelling it + from the background render thread that publishes completion is not safe. + """ + if self._render_finished: + if self._usage_timer is not None: + self._usage_timer.stop() + self._usage_timer = None + return + if self._usage_paused: + return + display_usage_summary(self, self.run_state.rendered_functionalities, self.run_state.get_live_render_time()) + + def _finalize_usage_summary(self) -> None: + """Freeze the usage line at its final totals once the render ends. + + Called from the completion/failure handlers (background render thread). The + render machine does not always finalize its accumulated render time before a + failure propagates, so the live value is captured onto the run state here. + That keeps the in-TUI line and the post-exit console summary in agreement on + every terminal path. The live timer stops on its own next tick; the + ``_render_finished`` guard blocks any overwrite. + """ + self.run_state.render_time_accumulated = self.run_state.get_live_render_time() + display_usage_summary( + self, + self.run_state.rendered_functionalities, + self.run_state.render_time_accumulated, + ) + def action_toggle_logs(self) -> None: """Toggle between dashboard and log view.""" switcher = self.query_one(f"#{TUIComponents.CONTENT_SWITCHER.value}", ContentSwitcher) @@ -253,12 +311,16 @@ def on_render_paused(self, event: RenderPaused): footer = self.screen.query_one(CustomFooter) footer.update_footer_state("paused") transition_frid_progress(self, ProgressItem.PAUSING, ProgressItem.PAUSED) - pass + # Stop refreshing the usage line so its render time is never sampled while + # the render machine sits in the pause loop; run_state already excludes the + # paused span once rendering resumes. + self._usage_paused = True def on_render_completed(self, event: RenderCompleted): """Handle successful render completion.""" self._render_success_handler.handle(event.rendered_code_path) self._render_finished = True + self._finalize_usage_summary() try: footer = self.screen.query_one(CustomFooter) footer.update_footer_state("finished") @@ -269,6 +331,7 @@ def on_render_failed(self, event: RenderFailed): """Handle render failure.""" self._render_error_handler.handle(event.error_message) self._render_finished = True + self._finalize_usage_summary() try: footer = self.screen.query_one(CustomFooter) footer.update_footer_state("finished") @@ -296,6 +359,7 @@ def action_pause(self) -> None: footer = self.screen.query_one(CustomFooter) footer.update_footer_state("rendering") self.enter_pause_event.clear() + self._usage_paused = False else: transition_frid_progress(self, ProgressItem.PROCESSING, ProgressItem.PAUSING) footer = self.screen.query_one(CustomFooter) @@ -315,4 +379,8 @@ def action_quit(self) -> None: """ if not self._render_finished and self._on_cancel: self._on_cancel() + # The render thread is abandoned on cancel and never finalizes the + # accumulated render time, so capture the live value now for the + # post-exit summary (otherwise it would report 0). + self.run_state.render_time_accumulated = self.run_state.get_live_render_time() self.exit() diff --git a/tui/styles.css b/tui/styles.css index d8e1f83a..945d5af1 100644 --- a/tui/styles.css +++ b/tui/styles.css @@ -53,6 +53,10 @@ We might need those at some point, so just commenting them out for now. color: #ff6b6b; } +#render-usage-widget { + height: auto; +} + #frid-progress { margin: 0; height: auto; diff --git a/tui/widget_helpers.py b/tui/widget_helpers.py index f081302e..7bc9ffe2 100644 --- a/tui/widget_helpers.py +++ b/tui/widget_helpers.py @@ -5,6 +5,8 @@ from textual.css.query import NoMatches from textual.widgets import Static +from usage_summary import format_usage_summary + from .components import FRIDProgress, ProgressItem, RenderingInfoBox, StructuredLogView, SubstateLine, TUIComponents from .models import Substate @@ -87,7 +89,10 @@ def display_success_message(tui, rendered_code_path: str): rendered_code_path: The path to the rendered code """ - message = f"[#79FC96]✓ Rendering finished![/#79FC96] [#888888](press enter to exit)[/#888888]\n[#888888]Generated code: {rendered_code_path}[/#888888] " + message = ( + f"[#79FC96]✓ rendering completed![/#79FC96] [#888888](press enter to exit)[/#888888]\n" + f"[#888888]generated code folder: {rendered_code_path}[/#888888] " + ) widget: Static = tui.query_one(f"#{TUIComponents.RENDER_STATUS_WIDGET.value}", Static) widget.update(message) @@ -118,6 +123,20 @@ def display_error_message(tui, error_message: str): widget.update(error_message) +def display_usage_summary(tui, functionalities: int, render_time_seconds: float) -> None: + """Update the credit-usage line beneath the render-status widget. + + Shows how many functionalities were rendered, the credits they consumed + (one per functionality), and the render time so far. Fails silently if the + widget is not mounted (e.g. during teardown). + """ + try: + widget: Static = tui.query_one(f"#{TUIComponents.RENDER_USAGE_WIDGET.value}", Static) + widget.update(format_usage_summary(functionalities, render_time_seconds)) + except NoMatches: + pass + + def update_progress_item_substates(tui, widget_id: str, substates: list[Substate]) -> None: """Helper function to safely set substates for a ProgressItem. diff --git a/usage_summary.py b/usage_summary.py new file mode 100644 index 00000000..6a4bea51 --- /dev/null +++ b/usage_summary.py @@ -0,0 +1,30 @@ +"""Shared rendering of the credit-usage summary line. + +The same 'functionalities / used credits / render time' line is shown by the +non-interactive console summary (``cli_output``) and by the interactive TUI +(``tui``), so its wording and markup live in this neutral root module as a single +source of truth. It intentionally has no dependency on either presentation +package, which keeps the TUI independent of the renderer/console output layer. +""" + +from plain2code_utils import format_duration_hms + + +def format_usage_summary( + functionalities: int, + render_time_seconds: float, + label_color: str = "#8E8F91", + value_color: str = "#FFFFFF", +) -> str: + """Build the shared 'functionalities / used credits / render time' usage line. + + Used credits equals the number of rendered functionalities (one credit is + charged per functional requirement). The returned string carries Rich markup + and is consumed identically by the console summary and the TUI. + """ + used_credits = functionalities + return ( + f"[{label_color}]functionalities [{value_color}]{functionalities} " + f"[{label_color}]used credits [{value_color}]{used_credits} " + f"[{label_color}]render time [{value_color}]{format_duration_hms(render_time_seconds)}" + )