From 2dd6145bd8a4b101dcaee394f48ee456a34b5484 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 23 Feb 2026 16:38:23 -0500 Subject: [PATCH] feat(gui): Implement event-driven API metrics updates and decouple from render loop --- gui.py | 123 ++++++++++++++++++++-------------- tests/test_gui_diagnostics.py | 2 +- tests/test_gui_events.py | 62 +++++++++++++++++ tests/test_gui_updates.py | 8 +-- 4 files changed, 138 insertions(+), 57 deletions(-) create mode 100644 tests/test_gui_events.py diff --git a/gui.py b/gui.py index cc16886..f66521f 100644 --- a/gui.py +++ b/gui.py @@ -496,6 +496,11 @@ class App: self._is_script_blinking = False self._script_blink_start_time = 0.0 + # Subscribe to API lifecycle events + ai_client.events.on("request_start", self._on_api_event) + ai_client.events.on("response_received", self._on_api_event) + ai_client.events.on("tool_execution", self._on_api_event) + self.perf_monitor = PerformanceMonitor() self.perf_history = { "frame_time": [0.0] * 100, @@ -822,40 +827,44 @@ class App: total = usage["input_tokens"] + usage["output_tokens"] dpg.set_value("ai_token_usage", f"Tokens: {total} (In: {usage['input_tokens']} Out: {usage['output_tokens']})") - def _update_telemetry_panel(self): - """Updates the token budget visualizer in the Provider panel.""" - # Update history bleed stats for all providers (throttled) - now = time.time() - if now - self._last_bleed_update_time > 2.0: - self._last_bleed_update_time = now - stats = ai_client.get_history_bleed_stats() - if dpg.does_item_exist("token_budget_bar"): - percentage = stats.get("percentage", 0.0) - dpg.set_value("token_budget_bar", percentage / 100.0 if percentage else 0.0) - if dpg.does_item_exist("token_budget_label"): - current = stats.get("current", 0) - limit = stats.get("limit", 0) - dpg.set_value("token_budget_label", f"{current:,} / {limit:,}") + def _on_api_event(self, *args, **kwargs): + """Callback for ai_client events. Queues a telemetry refresh on the main thread.""" + with self._pending_gui_tasks_lock: + self._pending_gui_tasks.append({"action": "refresh_api_metrics"}) - # Update Gemini-specific cache stats (throttled with diagnostics) - if now - self._last_diag_update_time > 10.0: - self._last_diag_update_time = now - - if dpg.does_item_exist("gemini_cache_label"): - if self.current_provider == "gemini": - try: - cache_stats = ai_client.get_gemini_cache_stats() - count = cache_stats.get("cache_count", 0) - size_bytes = cache_stats.get("total_size_bytes", 0) - size_kb = size_bytes / 1024.0 - text = f"Gemini Caches: {count} ({size_kb:.1f} KB)" - dpg.set_value("gemini_cache_label", text) - dpg.configure_item("gemini_cache_label", show=True) - except Exception as e: - # If the API call fails, just hide the label - dpg.configure_item("gemini_cache_label", show=False) - else: + def _refresh_api_metrics(self): + """Updates the token budget and cache stats visualizers.""" + self._last_bleed_update_time = time.time() + + # History bleed + stats = ai_client.get_history_bleed_stats() + if dpg.does_item_exist("token_budget_bar"): + percentage = stats.get("percentage", 0.0) + dpg.set_value("token_budget_bar", percentage / 100.0 if percentage else 0.0) + if dpg.does_item_exist("token_budget_label"): + current = stats.get("current", 0) + limit = stats.get("limit", 0) + dpg.set_value("token_budget_label", f"{current:,} / {limit:,}") + + # Gemini cache + if dpg.does_item_exist("gemini_cache_label"): + if self.current_provider == "gemini": + try: + cache_stats = ai_client.get_gemini_cache_stats() + count = cache_stats.get("cache_count", 0) + size_bytes = cache_stats.get("total_size_bytes", 0) + size_kb = size_bytes / 1024.0 + text = f"Gemini Caches: {count} ({size_kb:.1f} KB)" + dpg.set_value("gemini_cache_label", text) + dpg.configure_item("gemini_cache_label", show=True) + except Exception: dpg.configure_item("gemini_cache_label", show=False) + else: + dpg.configure_item("gemini_cache_label", show=False) + + def _update_performance_diagnostics(self): + """Updates performance diagnostics displays (throttled).""" + now = time.time() # Update Diagnostics panel (throttled for smoothness) if now - self._last_perf_update_time > 0.5: @@ -2221,6 +2230,34 @@ class App: dpg.add_line_series(list(range(100)), self.perf_history["cpu"], label="cpu usage", tag="perf_cpu_plot") dpg.set_axis_limits("axis_cpu_y", 0, 100) + def _process_pending_gui_tasks(self): + """Processes tasks queued from background threads on the main thread.""" + if not self._pending_gui_tasks: + return + + with self._pending_gui_tasks_lock: + gui_tasks = self._pending_gui_tasks[:] + self._pending_gui_tasks.clear() + + for task in gui_tasks: + try: + action = task.get("action") + if action == "set_value": + item = task.get("item") + val = task.get("value") + if item and dpg.does_item_exist(item): + dpg.set_value(item, val) + elif action == "click": + item = task.get("item") + if item and dpg.does_item_exist(item): + cb = dpg.get_item_callback(item) + if cb: + cb() + elif action == "refresh_api_metrics": + self._refresh_api_metrics() + except Exception as e: + print(f"Error executing GUI hook task: {e}") + def run(self): dpg.create_context() dpg.configure_app(docking=True, docking_space=True, init_file="dpg_layout.ini") @@ -2272,25 +2309,7 @@ class App: # Process queued API GUI tasks self.perf_monitor.start_component("GUI_Tasks") - with self._pending_gui_tasks_lock: - gui_tasks = self._pending_gui_tasks[:] - self._pending_gui_tasks.clear() - for task in gui_tasks: - try: - action = task.get("action") - if action == "set_value": - item = task.get("item") - val = task.get("value") - if item and dpg.does_item_exist(item): - dpg.set_value(item, val) - elif action == "click": - item = task.get("item") - if item and dpg.does_item_exist(item): - cb = dpg.get_item_callback(item) - if cb: - cb() - except Exception as e: - print(f"Error executing GUI hook task: {e}") + self._process_pending_gui_tasks() self.perf_monitor.end_component("GUI_Tasks") # Handle retro arcade blinking effect @@ -2394,7 +2413,7 @@ class App: self.perf_monitor.end_component("Comms") self.perf_monitor.start_component("Telemetry") - self._update_telemetry_panel() + self._update_performance_diagnostics() self.perf_monitor.end_component("Telemetry") self.perf_monitor.end_frame() diff --git a/tests/test_gui_diagnostics.py b/tests/test_gui_diagnostics.py index 28f5cf7..93dd7bc 100644 --- a/tests/test_gui_diagnostics.py +++ b/tests/test_gui_diagnostics.py @@ -53,7 +53,7 @@ def test_diagnostics_panel_updates(app_instance): # We also need to mock ai_client stats with patch('ai_client.get_history_bleed_stats', return_value={}): - app_instance._update_telemetry_panel() + app_instance._update_performance_diagnostics() # Verify UI updates mock_set_value.assert_any_call("perf_fps_text", "100.0") diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py new file mode 100644 index 0000000..d7bece0 --- /dev/null +++ b/tests/test_gui_events.py @@ -0,0 +1,62 @@ + +import pytest +from unittest.mock import MagicMock, patch +import dearpygui.dearpygui as dpg +import gui +from gui import App +import ai_client + +@pytest.fixture +def app_instance(): + """ + Fixture to create an instance of the App class for testing. + It creates a real DPG context but mocks functions that would + render a window or block execution. + """ + dpg.create_context() + + with patch('dearpygui.dearpygui.create_viewport'), \ + patch('dearpygui.dearpygui.setup_dearpygui'), \ + patch('dearpygui.dearpygui.show_viewport'), \ + patch('dearpygui.dearpygui.start_dearpygui'), \ + patch('gui.load_config', return_value={}), \ + patch('gui.PerformanceMonitor'), \ + patch('gui.shell_runner'), \ + patch('gui.project_manager'), \ + patch.object(App, '_load_active_project'), \ + patch.object(App, '_rebuild_files_list'), \ + patch.object(App, '_rebuild_shots_list'), \ + patch.object(App, '_rebuild_disc_list'), \ + patch.object(App, '_rebuild_disc_roles_list'), \ + patch.object(App, '_rebuild_discussion_selector'), \ + patch.object(App, '_refresh_project_widgets'): + + app = App() + yield app + + dpg.destroy_context() + +def test_gui_updates_on_event(app_instance): + # Patch dependencies for the test + with patch('dearpygui.dearpygui.set_value') as mock_set_value, \ + patch('dearpygui.dearpygui.does_item_exist', return_value=True), \ + patch('dearpygui.dearpygui.configure_item'), \ + patch('ai_client.get_history_bleed_stats') as mock_stats: + + mock_stats.return_value = {"percentage": 50.0, "current": 500, "limit": 1000} + + # We'll use patch.object to see if _refresh_api_metrics is called + with patch.object(app_instance, '_refresh_api_metrics', wraps=app_instance._refresh_api_metrics) as mock_refresh: + # Simulate event + ai_client.events.emit("response_received", payload={}) + + # Process tasks manually + app_instance._process_pending_gui_tasks() + + # Verify that _refresh_api_metrics was called + mock_refresh.assert_called_once() + + # Verify that dpg.set_value was called for the metrics widgets + calls = [call.args[0] for call in mock_set_value.call_args_list] + assert "token_budget_bar" in calls + assert "token_budget_label" in calls diff --git a/tests/test_gui_updates.py b/tests/test_gui_updates.py index ba349d0..2834a39 100644 --- a/tests/test_gui_updates.py +++ b/tests/test_gui_updates.py @@ -41,7 +41,7 @@ def app_instance(): def test_telemetry_panel_updates_correctly(app_instance): """ - Tests that the _update_telemetry_panel method correctly updates + Tests that the _update_performance_diagnostics method correctly updates DPG widgets based on the stats from ai_client. """ # 1. Set the provider to anthropic @@ -64,7 +64,7 @@ def test_telemetry_panel_updates_correctly(app_instance): patch('dearpygui.dearpygui.does_item_exist', return_value=True) as mock_does_item_exist: # 4. Call the method under test - app_instance._update_telemetry_panel() + app_instance._refresh_api_metrics() # 5. Assert the results mock_get_stats.assert_called_once() @@ -78,7 +78,7 @@ def test_telemetry_panel_updates_correctly(app_instance): def test_cache_data_display_updates_correctly(app_instance): """ - Tests that the _update_telemetry_panel method correctly updates the + Tests that the _update_performance_diagnostics method correctly updates the GUI with Gemini cache statistics when the provider is set to Gemini. """ # 1. Set the provider to Gemini @@ -104,7 +104,7 @@ def test_cache_data_display_updates_correctly(app_instance): with patch('ai_client.get_history_bleed_stats', return_value={}): # 4. Call the method under test - app_instance._update_telemetry_panel() + app_instance._refresh_api_metrics() # 5. Assert the results mock_get_cache_stats.assert_called_once()