diff --git a/src/events.py b/src/events.py index e7ac2fd..65df2d4 100644 --- a/src/events.py +++ b/src/events.py @@ -91,7 +91,14 @@ class AsyncEventQueue: """ self._queue.put((event_name, payload)) if self.websocket_server: - self.websocket_server.broadcast("events", {"event": event_name, "payload": payload}) + # Ensure payload is JSON serializable for websocket broadcast + serializable_payload = payload + if hasattr(payload, 'to_dict'): + serializable_payload = payload.to_dict() + elif hasattr(payload, '__dict__'): + serializable_payload = vars(payload) + + self.websocket_server.broadcast("events", {"event": event_name, "payload": serializable_payload}) def get(self) -> Tuple[str, Any]: """ diff --git a/src/gui_2.py b/src/gui_2.py index 7753cc8..83e3932 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -2253,7 +2253,7 @@ def hello(): def _render_discussion_panel(self) -> None: if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_discussion_panel") - # THINKING indicator + # THINKING indicator is_thinking = self.ai_status in ['sending...', 'streaming...', 'running powershell...'] if is_thinking: val = math.sin(time.time() * 10 * math.pi) @@ -2262,12 +2262,10 @@ def hello(): if theme.is_nerv_active(): c = vec4(255, 50, 50, alpha) # More vibrant for NERV imgui.text_colored(c, "THINKING...") - imgui.separator() - # Prior session viewing mode + imgui.same_line() + if self.is_viewing_prior_session: imgui.push_style_color(imgui.Col_.child_bg, vec4(50, 40, 20)) - imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION") - imgui.same_line() if imgui.button("Exit Prior Session"): self.controller.cb_exit_prior_session() self._comms_log_dirty = True @@ -2289,7 +2287,7 @@ def hello(): if ts: imgui.same_line() imgui.text_colored(vec4(160, 160, 160), str(ts)) - + content = entry.get("content", "") if collapsed: imgui.same_line() @@ -2301,12 +2299,14 @@ def hello(): if is_nerv: imgui.push_style_color(imgui.Col_.text, vec4(80, 255, 80)) markdown_helper.render(content, context_id=f'prior_disc_{idx}') if is_nerv: imgui.pop_style_color() - + imgui.separator() imgui.pop_id() imgui.end_child() imgui.pop_style_color() + if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_discussion_panel") return + if not self.is_viewing_prior_session and imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open): names = self._get_discussion_names() grouped_discussions = {} @@ -2335,13 +2335,14 @@ def hello(): for take_name in current_takes: label = "Original" if take_name == active_base else take_name.replace(f"{active_base}_", "").replace("_", " ").title() flags = imgui.TabItemFlags_.set_selected if take_name == self.active_discussion else 0 - opened, _ = imgui.begin_tab_item(f"{label}###{take_name}", None, flags) - if opened: + res = imgui.begin_tab_item(f"{label}###{take_name}", None, flags) + if res[0]: if take_name != self.active_discussion: self._switch_discussion(take_name) imgui.end_tab_item() - if imgui.begin_tab_item("Synthesis###Synthesis"): + res_s = imgui.begin_tab_item("Synthesis###Synthesis") + if res_s[0]: self._render_synthesis_panel() imgui.end_tab_item() @@ -2373,10 +2374,13 @@ def hello(): self._flush_disc_entries_to_project() # Restore project discussion self._switch_discussion(self.active_discussion) + self.ai_status = "track discussion disabled" + disc_sec = self.project.get("discussion", {}) disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) git_commit = disc_data.get("git_commit", "") last_updated = disc_data.get("last_updated", "") + imgui.text_colored(C_LBL, "commit:") imgui.same_line() self._render_selectable_label('git_commit_val', git_commit[:12] if git_commit else '(none)', width=100, color=(C_IN if git_commit else C_LBL)) @@ -2389,9 +2393,11 @@ def hello(): disc_data["git_commit"] = cmt disc_data["last_updated"] = project_manager.now_ts() self.ai_status = f"commit: {cmt[:12]}" + imgui.text_colored(C_LBL, "updated:") imgui.same_line() imgui.text_colored(C_SUB, last_updated if last_updated else "(never)") + ch, self.ui_disc_new_name_input = imgui.input_text("##new_disc", self.ui_disc_new_name_input) imgui.same_line() if imgui.button("Create"): @@ -2404,6 +2410,7 @@ def hello(): imgui.same_line() if imgui.button("Delete"): self._delete_discussion(self.active_discussion) + if not self.is_viewing_prior_session: imgui.separator() if imgui.button("+ Entry"): @@ -2423,6 +2430,7 @@ def hello(): self._flush_to_config() models.save_config(self.config) self.ai_status = "discussion saved" + ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history) # Truncation controls imgui.text("Keep Pairs:") @@ -2435,15 +2443,19 @@ def hello(): with self._disc_entries_lock: self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs) self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs" + imgui.separator() if imgui.collapsing_header("Roles"): imgui.begin_child("roles_scroll", imgui.ImVec2(0, 100), True) for i, r in enumerate(self.disc_roles): - if imgui.button(f"x##r{i}"): + imgui.push_id(f"role_{i}") + if imgui.button("X"): self.disc_roles.pop(i) + imgui.pop_id() break imgui.same_line() imgui.text(r) + imgui.pop_id() imgui.end_child() ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input) imgui.same_line() @@ -2452,9 +2464,10 @@ def hello(): if r and r not in self.disc_roles: self.disc_roles.append(r) self.ui_disc_new_role_input = "" + imgui.separator() imgui.begin_child("disc_scroll", imgui.ImVec2(0, 0), False) - + # Filter entries based on focused agent persona display_entries = self.disc_entries if self.ui_focus_agent: @@ -2485,10 +2498,12 @@ def hello(): if imgui.selectable(r, r == entry["role"])[0]: entry["role"] = r imgui.end_combo() + if not collapsed: imgui.same_line() if imgui.button("[Edit]" if read_mode else "[Read]"): entry["read_mode"] = not read_mode + ts_str = entry.get("ts", "") if ts_str: imgui.same_line() @@ -2509,6 +2524,7 @@ def hello(): if imgui.is_item_hovered(): tooltip = "Files injected at this point:\n" + "\n".join([f.path for f in injected_here]) imgui.set_tooltip(tooltip) + if collapsed: imgui.same_line() if imgui.button("Ins"): @@ -2522,10 +2538,10 @@ def hello(): if imgui.button("Branch"): self._branch_discussion(i) imgui.same_line() - preview = entry["content"].replace("\\n", " ")[:60] + preview = entry["content"].replace("\n", " ")[:60] if len(entry["content"]) > 60: preview += "..." if not preview.strip() and entry.get("thinking_segments"): - preview = entry["thinking_segments"][0]["content"].replace("\\n", " ")[:60] + preview = entry["thinking_segments"][0]["content"].replace("\n", " ")[:60] if len(entry["thinking_segments"][0]["content"]) > 60: preview += "..." imgui.text_colored(vec4(160, 160, 150), preview) if not collapsed: @@ -2582,11 +2598,13 @@ def hello(): ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150)) imgui.separator() imgui.pop_id() + if self._scroll_disc_to_bottom: imgui.set_scroll_here_y(1.0) self._scroll_disc_to_bottom = False imgui.end_child() + if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_discussion_panel") def _render_synthesis_panel(self) -> None: """Renders a panel for synthesizing multiple discussion takes.""" diff --git a/tests/test_file_item_model.py b/tests/test_file_item_model.py index f1ff936..a549514 100644 --- a/tests/test_file_item_model.py +++ b/tests/test_file_item_model.py @@ -7,6 +7,7 @@ def test_file_item_fields(): assert item.path == "src/models.py" assert item.auto_aggregate is True assert item.force_full is False + assert item.injected_at is None def test_file_item_to_dict(): """Test that FileItem can be serialized to a dict.""" @@ -14,7 +15,8 @@ def test_file_item_to_dict(): expected = { "path": "test.py", "auto_aggregate": False, - "force_full": True + "force_full": True, + "injected_at": None } assert item.to_dict() == expected @@ -23,12 +25,14 @@ def test_file_item_from_dict(): data = { "path": "test.py", "auto_aggregate": False, - "force_full": True + "force_full": True, + "injected_at": 123.456 } item = FileItem.from_dict(data) assert item.path == "test.py" assert item.auto_aggregate is False assert item.force_full is True + assert item.injected_at == 123.456 def test_file_item_from_dict_defaults(): """Test that FileItem.from_dict handles missing fields.""" @@ -37,3 +41,4 @@ def test_file_item_from_dict_defaults(): assert item.path == "test.py" assert item.auto_aggregate is True assert item.force_full is False + assert item.injected_at is None diff --git a/tests/test_gui_symbol_navigation.py b/tests/test_gui_symbol_navigation.py index 12df1b9..e3a38ee 100644 --- a/tests/test_gui_symbol_navigation.py +++ b/tests/test_gui_symbol_navigation.py @@ -8,7 +8,8 @@ def test_render_discussion_panel_symbol_lookup(mock_app, role): with ( patch('src.gui_2.imgui') as mock_imgui, patch('src.gui_2.mcp_client') as mock_mcp, - patch('src.gui_2.project_manager') as mock_pm + patch('src.gui_2.project_manager') as mock_pm, + patch('src.markdown_helper.imgui_md') as mock_md ): # Set up App instance state mock_app.perf_profiling_enabled = False diff --git a/tests/test_mma_approval_indicators.py b/tests/test_mma_approval_indicators.py index 2e0a96b..350fc14 100644 --- a/tests/test_mma_approval_indicators.py +++ b/tests/test_mma_approval_indicators.py @@ -5,7 +5,7 @@ from src.gui_2 import App def _make_app(**kwargs): - app = MagicMock(spec=App) + app = MagicMock() app.mma_streams = kwargs.get("mma_streams", {}) app.mma_tier_usage = kwargs.get("mma_tier_usage", { "Tier 1": {"input": 0, "output": 0, "model": "gemini-3.1-pro-preview"}, @@ -13,6 +13,7 @@ def _make_app(**kwargs): "Tier 3": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"}, "Tier 4": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"}, }) + app.ui_focus_agent = kwargs.get("ui_focus_agent", None) app.tracks = kwargs.get("tracks", []) app.active_track = kwargs.get("active_track", None) app.active_tickets = kwargs.get("active_tickets", [])