diff --git a/src/app_controller.py b/src/app_controller.py index f378282..c6b7664 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -149,6 +149,9 @@ class AppController: self._cached_cache_stats: Dict[str, Any] = {} # Pre-computed cache stats for GUI self._token_history: List[Dict[str, Any]] = [] # Token usage over time [{"time": t, "input": n, "output": n, "model": s}, ...] self._session_start_time: float = time.time() # For calculating burn rate + self._ticket_start_times: dict[str, float] = {} + self._avg_ticket_time: float = 0.0 + self._completed_ticket_count: int = 0 self._comms_log: List[Dict[str, Any]] = [] self.session_usage: Dict[str, Any] = { "input_tokens": 0, @@ -573,6 +576,21 @@ class AppController: self._mma_spawn_edit_mode = False if "dialog_container" in task: task["dialog_container"][0] = spawn_dlg + elif action == "ticket_started": + payload = task.get("payload", {}) + ticket_id = payload.get("ticket_id") + start_time = payload.get("timestamp") + if ticket_id and start_time: + self._ticket_start_times[ticket_id] = start_time + elif action == "ticket_completed": + payload = task.get("payload", {}) + ticket_id = payload.get("ticket_id") + end_time = payload.get("timestamp") + if ticket_id and end_time and ticket_id in self._ticket_start_times: + start_time = self._ticket_start_times.pop(ticket_id) + elapsed = end_time - start_time + self._completed_ticket_count += 1 + self._avg_ticket_time = ((self._avg_ticket_time * (self._completed_ticket_count - 1)) + elapsed) / self._completed_ticket_count except Exception as e: import traceback sys.stderr.write(f"[DEBUG] Error executing GUI task: {e}\n{traceback.format_exc()}\n") @@ -902,6 +920,18 @@ class AppController: if self.test_hooks_enabled: with self._api_event_queue_lock: self._api_event_queue.append({"type": "response", "payload": payload}) + elif event_name == "ticket_started": + with self._pending_gui_tasks_lock: + self._pending_gui_tasks.append({ + "action": "ticket_started", + "payload": payload + }) + elif event_name == "ticket_completed": + with self._pending_gui_tasks_lock: + self._pending_gui_tasks.append({ + "action": "ticket_completed", + "payload": payload + }) def _handle_request_event(self, event: events.UserRequestEvent) -> None: """Processes a UserRequestEvent by calling the AI client.""" diff --git a/src/gui_2.py b/src/gui_2.py index d721fdc..7d2c867 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -1,4 +1,4 @@ -# gui_2.py +# gui_2.py from __future__ import annotations import tomli_w import time @@ -1752,26 +1752,21 @@ class App: def _render_mma_dashboard(self) -> None: # Task 5.3: Dense Summary Line track_name = self.active_track.description if self.active_track else "None" - total_tickets = len(self.active_tickets) - done_tickets = sum(1 for t in self.active_tickets if t.get('status') == 'complete') + track_stats = {"percentage": 0.0, "completed": 0, "total": 0, "in_progress": 0, "blocked": 0, "todo": 0} + if self.active_track: + track_stats = project_manager.calculate_track_progress(self.active_track.tickets) + total_cost = 0.0 - for stats in self.mma_tier_usage.values(): - model = stats.get('model', 'unknown') - in_t = stats.get('input', 0) - out_t = stats.get('output', 0) + for usage in self.mma_tier_usage.values(): + model = usage.get('model', 'unknown') + in_t = usage.get('input', 0) + out_t = usage.get('output', 0) total_cost += cost_tracker.estimate_cost(model, in_t, out_t) + imgui.text("Track:") imgui.same_line() imgui.text_colored(C_VAL, track_name) imgui.same_line() - imgui.text(" | Tickets:") - imgui.same_line() - imgui.text_colored(C_VAL, f"{done_tickets}/{total_tickets}") - imgui.same_line() - imgui.text(" | Cost:") - imgui.same_line() - imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), f"${total_cost:,.4f}") - imgui.same_line() imgui.text(" | Status:") imgui.same_line() status_col = imgui.ImVec4(1, 1, 1, 1) @@ -1780,7 +1775,54 @@ class App: elif self.mma_status == "done": status_col = imgui.ImVec4(0, 1, 0, 1) elif self.mma_status == "error": status_col = imgui.ImVec4(1, 0, 0, 1) imgui.text_colored(status_col, self.mma_status.upper()) - + imgui.same_line() + imgui.text(" | Cost:") + imgui.same_line() + imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), f"${total_cost:,.4f}") + + # Progress Bar + perc = track_stats["percentage"] / 100.0 + p_color = imgui.ImVec4(0.0, 1.0, 0.0, 1.0) + if track_stats["percentage"] < 33: + p_color = imgui.ImVec4(1.0, 0.0, 0.0, 1.0) + elif track_stats["percentage"] < 66: + p_color = imgui.ImVec4(1.0, 1.0, 0.0, 1.0) + + imgui.push_style_color(imgui.Col_.plot_histogram, p_color) + imgui.progress_bar(perc, imgui.ImVec2(-1, 0), f"{track_stats['percentage']:.1f}%") + imgui.pop_style_color() + + # Detailed breakdown + if imgui.begin_table("ticket_stats_breakdown", 4): + imgui.table_next_column() + imgui.text_colored(C_LBL, "Completed:") + imgui.same_line() + imgui.text_colored(C_VAL, str(track_stats["completed"])) + + imgui.table_next_column() + imgui.text_colored(C_LBL, "In Progress:") + imgui.same_line() + imgui.text_colored(C_VAL, str(track_stats["in_progress"])) + + imgui.table_next_column() + imgui.text_colored(C_LBL, "Blocked:") + imgui.same_line() + imgui.text_colored(C_VAL, str(track_stats["blocked"])) + + imgui.table_next_column() + imgui.text_colored(C_LBL, "Todo:") + imgui.same_line() + imgui.text_colored(C_VAL, str(track_stats["todo"])) + + imgui.end_table() + + if self.active_track: + remaining = track_stats["total"] - track_stats["completed"] + eta_mins = (self._avg_ticket_time * remaining) / 60.0 + imgui.text_colored(C_LBL, "ETA:") + imgui.same_line() + imgui.text_colored(C_VAL, f"~{int(eta_mins)}m ({remaining} tickets remaining)") + imgui.separator() imgui.text_colored(C_LBL, 'Epic Planning (Tier 1)') _, self.ui_epic_input = imgui.input_text_multiline('##epic_input', self.ui_epic_input, imgui.ImVec2(-1, 80)) @@ -1874,20 +1916,7 @@ class App: if imgui.button("Go to Approval"): pass # scroll/focus handled by existing dialog rendering imgui.separator() - # 2. Active Track Info - if self.active_track: - imgui.text(f"Track: {self.active_track.description}") - # Progress bar - tickets = self.active_tickets - total = len(tickets) - if total > 0: - complete = sum(1 for t in tickets if t.get('status') == 'complete') - progress = complete / total - imgui.progress_bar(progress, imgui.ImVec2(-1, 0), f"{complete}/{total} Tickets") - else: - imgui.text_disabled("No active MMA track.") - # 3. Token Usage Table - imgui.separator() + # 3. Token Usage Table imgui.text("Tier Usage (Tokens & Cost)") if imgui.begin_table("mma_usage", 5, imgui.TableFlags_.borders | imgui.TableFlags_.row_bg): imgui.table_setup_column("Tier") diff --git a/src/multi_agent_conductor.py b/src/multi_agent_conductor.py index cae896c..743787f 100644 --- a/src/multi_agent_conductor.py +++ b/src/multi_agent_conductor.py @@ -218,6 +218,7 @@ class ConductorEngine: if spawned: ticket.status = "in_progress" + _queue_put(self.event_queue, "ticket_started", {"ticket_id": ticket.id, "timestamp": time.time()}) print(f"Executing ticket {ticket.id}: {ticket.description}") self._push_state(active_tier=f"Tier 3 (Worker): {ticket.id}") @@ -368,6 +369,8 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: ) if not approved: ticket.mark_blocked("Spawn rejected by user.") + if event_queue: + _queue_put(event_queue, "ticket_completed", {"ticket_id": ticket.id, "timestamp": time.time()}) return "BLOCKED: Spawn rejected by user." user_message = modified_prompt md_content = modified_context @@ -417,7 +420,7 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: ai_client.comms_log_callback = old_comms_cb ai_client.set_current_tier(None) if event_queue: - # Push via "response" event type — _process_event_queue wraps this + # Push via "response" event type — _process_event_queue wraps this # as {"action": "handle_ai_response", "payload": ...} for the GUI. try: response_payload = { @@ -441,4 +444,7 @@ def run_worker_lifecycle(ticket: Ticket, context: WorkerContext, context_files: ticket.mark_blocked(response) else: ticket.mark_complete() + + if event_queue: + _queue_put(event_queue, "ticket_completed", {"ticket_id": ticket.id, "timestamp": time.time()}) return response diff --git a/tests/test_gui_progress.py b/tests/test_gui_progress.py new file mode 100644 index 0000000..86f9e8e --- /dev/null +++ b/tests/test_gui_progress.py @@ -0,0 +1,74 @@ +import pytest +from unittest.mock import MagicMock, patch +from src.gui_2 import App, C_LBL, C_VAL +from src.models import Ticket + +def test_render_mma_dashboard_progress(): + # Create a mock for the imgui module used in gui_2 + mock_imgui = MagicMock() + + # Configure ImVec4 and ImVec2 + mock_imgui.ImVec2 = MagicMock(side_effect=lambda x, y: (float(x), float(y))) + mock_imgui.ImVec4 = MagicMock(side_effect=lambda r, g, b, a: (float(r), float(g), float(b), float(a))) + + # Configure calls that return tuples + mock_imgui.input_text_multiline.return_value = (False, "") + mock_imgui.input_text.return_value = (False, "") + mock_imgui.checkbox.return_value = (False, False) + mock_imgui.begin_combo.return_value = (False, "") + mock_imgui.selectable.return_value = (False, False) + mock_imgui.begin_table.return_value = True + mock_imgui.collapsing_header.return_value = False + + # Patch where it is actually used + with patch('src.gui_2.imgui', mock_imgui), \ + patch('src.gui_2.cost_tracker.estimate_cost', return_value=0.0): + + # Mock App instance - no spec because of delegation + app = MagicMock() + + # Setup mock state + app.active_track = MagicMock() + app.active_track.description = "Test Track" + + # Mock self.active_track.tickets as a list of src.models.Ticket objects + app.active_track.tickets = [ + Ticket(id='T1', description='desc', status='completed'), + Ticket(id='T2', description='desc', status='in_progress'), + Ticket(id='T3', description='desc', status='blocked'), + Ticket(id='T4', description='desc', status='todo') + ] + + app.mma_tier_usage = {} + app.mma_status = "idle" + app.active_tier = None + app._pending_mma_spawn = None + app._pending_mma_approval = None + app._pending_ask_dialog = False + app.tracks = [] + app.ui_epic_input = "" + app.ui_conductor_setup_summary = "" + app.ui_new_track_name = "" + app.ui_new_track_desc = "" + app.ui_new_track_type = "feature" + app.mma_step_mode = False + app.node_editor_ctx = None + app._avg_ticket_time = 60 + + # Call the method + App._render_mma_dashboard(app) + + # Assertions + # 1 completed out of 4 tickets = 25.0% progress + # Update assertions: imgui.progress_bar is called with (0.25, (-1.0, 0.0), '25.0%') + mock_imgui.progress_bar.assert_any_call(0.25, (-1.0, 0.0), "25.0%") + + # Verify status breakdown counts are asserted via imgui.text_colored calls + mock_imgui.text_colored.assert_any_call(C_LBL, "Completed:") + mock_imgui.text_colored.assert_any_call(C_VAL, "1") + mock_imgui.text_colored.assert_any_call(C_LBL, "In Progress:") + mock_imgui.text_colored.assert_any_call(C_VAL, "1") + mock_imgui.text_colored.assert_any_call(C_LBL, "Blocked:") + mock_imgui.text_colored.assert_any_call(C_VAL, "1") + mock_imgui.text_colored.assert_any_call(C_LBL, "Todo:") + mock_imgui.text_colored.assert_any_call(C_VAL, "1")