From e34a2e635543a531ee8abef0a0438fff8496cb47 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sun, 8 Mar 2026 21:37:22 -0400 Subject: [PATCH] feat(ui): Implement selectable text across primary GUI panels --- src/gui_2.py | 114 ++++++++++++++++++------------------ tests/test_selectable_ui.py | 47 +++++++++++++++ 2 files changed, 104 insertions(+), 57 deletions(-) create mode 100644 tests/test_selectable_ui.py diff --git a/src/gui_2.py b/src/gui_2.py index 410d6da..1e42dc1 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -211,33 +211,34 @@ class App: if len(content) > COMMS_CLAMP_CHARS: # Use a fixed-height child window with unformatted text for large text to avoid expensive frame-by-frame wrapping or input_text_multiline overhead imgui.begin_child(f"heavy_text_child_{label}_{hash(content)}", imgui.ImVec2(0, 80), True) - imgui.text_unformatted(content) + self._render_selectable_label(f'heavy_val_{label}_{hash(content)}', content, width=-1, multiline=True, height=80) imgui.end_child() else: - if self.ui_word_wrap: - imgui.push_text_wrap_pos(0.0) - imgui.text_unformatted(content) - imgui.pop_text_wrap_pos() - else: - imgui.text_unformatted(content) + self._render_selectable_label(f'heavy_val_{label}_{hash(content)}', content, width=-1, multiline=self.ui_word_wrap, height=0) # ---------------------------------------------------------------- gui def _render_selectable_label(self, label: str, value: str, width: float = 0.0, multiline: bool = False, height: float = 0.0, color: Optional[imgui.ImVec4] = None) -> None: imgui.push_id(label + str(hash(value))) - pops = 2 + pops = 4 imgui.push_style_color(imgui.Col_.frame_bg, vec4(0, 0, 0, 0)) + imgui.push_style_color(imgui.Col_.frame_bg_hovered, vec4(0, 0, 0, 0)) + imgui.push_style_color(imgui.Col_.frame_bg_active, vec4(0, 0, 0, 0)) imgui.push_style_color(imgui.Col_.border, vec4(0, 0, 0, 0)) if color: imgui.push_style_color(imgui.Col_.text, color) pops += 1 + imgui.push_style_var(imgui.StyleVar_.frame_border_size, 0.0) + imgui.push_style_var(imgui.StyleVar_.frame_padding, imgui.ImVec2(0, 0)) if multiline: imgui.input_text_multiline("##" + label, value, imgui.ImVec2(width, height), imgui.InputTextFlags_.read_only) else: if width > 0: imgui.set_next_item_width(width) imgui.input_text("##" + label, value, imgui.InputTextFlags_.read_only) imgui.pop_style_color(pops) + imgui.pop_style_var(2) imgui.pop_id() + def _show_menus(self) -> None: if imgui.begin_menu("manual slop"): if imgui.menu_item("Quit", "Ctrl+Q", False)[0]: @@ -1305,14 +1306,7 @@ class App: if len(content) > 80: preview += "..." imgui.text_colored(vec4(180, 180, 180), preview) else: - imgui.begin_child(f"prior_content_{idx}", imgui.ImVec2(0, 150), True) - if self.ui_word_wrap: - imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text_unformatted(content) - imgui.pop_text_wrap_pos() - else: - imgui.text_unformatted(content) - imgui.end_child() + self._render_selectable_label(f'prior_content_val_{idx}', content, width=-1, multiline=True, height=150) imgui.separator() imgui.pop_id() @@ -1349,7 +1343,7 @@ class App: last_updated = disc_data.get("last_updated", "") imgui.text_colored(C_LBL, "commit:") imgui.same_line() - imgui.text_colored(C_IN if git_commit else C_LBL, git_commit[:12] if git_commit else "(none)") + 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)) imgui.same_line() if imgui.button("Update Commit"): git_dir = self.ui_project_git_dir @@ -1467,37 +1461,41 @@ class App: imgui.text_colored(vec4(160, 160, 150), preview) if not collapsed: if read_mode: - imgui.begin_child("read_content", imgui.ImVec2(0, 150), True) - if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) content = entry["content"] - last_idx = 0 pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?") - for match in pattern.finditer(content): - before = content[last_idx:match.start()] - if before: imgui.text(before) - header_text = match.group(0).split("\n")[0].strip() - path = match.group(2) - code_block = match.group(4) - if imgui.collapsing_header(header_text): - if imgui.button(f"[Source]##{i}_{match.start()}"): - res = mcp_client.read_file(path) - if res: - self.text_viewer_title = path - self.text_viewer_content = res - self.show_text_viewer = True - if code_block: - code_content = code_block.strip() - if code_content.count("\n") + 1 > 50: - imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True) - imgui.text(code_content) - imgui.end_child() - else: - imgui.text(code_content) - last_idx = match.end() - after = content[last_idx:] - if after: imgui.text(after) - if self.ui_word_wrap: imgui.pop_text_wrap_pos() - imgui.end_child() + matches = list(pattern.finditer(content)) + if not matches: + self._render_selectable_label(f'read_content_{i}', content, width=-1, multiline=True, height=150) + else: + imgui.begin_child("read_content", imgui.ImVec2(0, 150), True) + if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) + last_idx = 0 + for m_idx, match in enumerate(matches): + before = content[last_idx:match.start()] + if before: self._render_selectable_label(f'read_before_{i}_{m_idx}', before, width=-1, multiline=True, height=0) + header_text = match.group(0).split("\n")[0].strip() + path = match.group(2) + code_block = match.group(4) + if imgui.collapsing_header(header_text): + if imgui.button(f"[Source]##{i}_{match.start()}"): + res = mcp_client.read_file(path) + if res: + self.text_viewer_title = path + self.text_viewer_content = res + self.show_text_viewer = True + if code_block: + code_content = code_block.strip() + if code_content.count("\n") + 1 > 50: + imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True) + imgui.text(code_content) + imgui.end_child() + else: + imgui.text(code_content) + last_idx = match.end() + after = content[last_idx:] + if after: self._render_selectable_label(f'read_after_{i}_{last_idx}', after, width=-1, multiline=True, height=0) + if self.ui_word_wrap: imgui.pop_text_wrap_pos() + imgui.end_child() else: ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150)) imgui.separator() @@ -1536,7 +1534,7 @@ class App: sid = "None" if hasattr(ai_client, "_gemini_cli_adapter") and ai_client._gemini_cli_adapter: sid = ai_client._gemini_cli_adapter.session_id or "None" - imgui.text(f"Session ID: {sid}") + imgui.text("Session ID:"); imgui.same_line(); self._render_selectable_label("gemini_cli_sid", sid, width=200) if imgui.button("Reset CLI Session"): ai_client.reset_session() imgui.text("Binary Path") @@ -1560,7 +1558,7 @@ class App: total = usage["input_tokens"] + usage["output_tokens"] if total == 0 and usage.get("total_tokens", 0) > 0: total = usage["total_tokens"] - imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})") + self._render_selectable_label("session_telemetry_tokens", f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})", width=-1, color=C_RES) if usage.get("last_latency", 0.0) > 0: imgui.text_colored(C_LBL, f" Last Latency: {usage['last_latency']:.2f}s") if usage["cache_read_input_tokens"]: @@ -1623,13 +1621,13 @@ class App: tokens = in_t + out_t cost = cost_tracker.estimate_cost(model, in_t, out_t) imgui.table_next_row() - imgui.table_set_column_index(0); imgui.text(tier) - imgui.table_set_column_index(1); imgui.text(model.split('-')[0]) - imgui.table_set_column_index(2); imgui.text(f"{tokens:,}") - imgui.table_set_column_index(3); imgui.text_colored(imgui.ImVec4(0.2, 0.9, 0.2, 1), f"${cost:.4f}") + imgui.table_set_column_index(0); self._render_selectable_label(f"tier_{tier}", tier, width=-1) + imgui.table_set_column_index(1); self._render_selectable_label(f"model_{tier}", model.split("-")[0], width=-1) + imgui.table_set_column_index(2); self._render_selectable_label(f"tokens_{tier}", f"{tokens:,}", width=-1) + imgui.table_set_column_index(3); self._render_selectable_label(f"cost_{tier}", f"${cost:.4f}", width=-1, color=imgui.ImVec4(0.2, 0.9, 0.2, 1)) imgui.end_table() tier_total = sum(cost_tracker.estimate_cost(stats.get('model', ''), stats.get('input', 0), stats.get('output', 0)) for stats in self.mma_tier_usage.values()) - imgui.text_colored(imgui.ImVec4(0, 1, 0, 1), f"Session Total: ${tier_total:.4f}") + self._render_selectable_label("session_total_cost", f"Session Total: ${tier_total:.4f}", width=-1, color=imgui.ImVec4(0, 1, 0, 1)) else: imgui.text_disabled("No MMA tier usage data") if stats.get("would_trim"): @@ -1974,7 +1972,8 @@ class App: script_preview = script.replace("\n", " ")[:150] if len(script) > 150: script_preview += "..." - if imgui.selectable(f"{script_preview}##tc_{i}", False, imgui.SelectableFlags_.span_all_columns)[0]: + self._render_selectable_label(f'tc_script_{i}', script_preview, width=-1) + if imgui.is_item_clicked(): self.text_viewer_title = f"Tool Call #{i+1} Details" self.text_viewer_content = combined self.show_text_viewer = True @@ -1982,7 +1981,8 @@ class App: imgui.table_next_column() res_preview = res.replace("\n", " ")[:30] if len(res) > 30: res_preview += "..." - if imgui.button(f"View##res_{i}"): + self._render_selectable_label(f'tc_res_{i}', res_preview, width=-1) + if imgui.is_item_clicked(): self.text_viewer_title = f"Tool Call #{i+1} Details" self.text_viewer_content = combined self.show_text_viewer = True @@ -2648,7 +2648,7 @@ class App: if stream_key is not None: content = self.mma_streams.get(stream_key, "") imgui.begin_child(f"##stream_content_{tier_key}", imgui.ImVec2(-1, -1)) - imgui.text_wrapped(content) + self._render_selectable_label(f'stream_{tier_key}', content, width=-1, multiline=True, height=0) try: if len(content) != self._tier_stream_last_len.get(stream_key, -1): imgui.set_scroll_here_y(1.0) @@ -2674,7 +2674,7 @@ class App: else: imgui.text(f"{ticket_id} [{status}]") imgui.begin_child(f"##tier3_{ticket_id}_scroll", imgui.ImVec2(-1, 150), True) - imgui.text_wrapped(self.mma_streams[key]) + self._render_selectable_label(f'stream_t3_{ticket_id}', self.mma_streams[key], width=-1, multiline=True, height=0) try: if len(self.mma_streams[key]) != self._tier_stream_last_len.get(key, -1): imgui.set_scroll_here_y(1.0) diff --git a/tests/test_selectable_ui.py b/tests/test_selectable_ui.py new file mode 100644 index 0000000..692891a --- /dev/null +++ b/tests/test_selectable_ui.py @@ -0,0 +1,47 @@ +import pytest +import time +import os +import sys + +# Ensure project root is in path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from src.api_hook_client import ApiHookClient +from src.gui_2 import App + +@pytest.mark.integration +def test_selectable_label_stability(live_gui) -> None: + """ + Verifies that the application starts correctly with --enable-test-hooks + and that the selectable label infrastructure is present and stable. + """ + client = ApiHookClient() + assert client.wait_for_server(timeout=20), "Hook server failed to start" + + # 1. Check initial state via diagnostics: is_viewing_prior_session should be False + diag = client.get_gui_diagnostics() + # Based on src/api_hooks.py: result["prior"] = _get_app_attr(app, "is_viewing_prior_session", False) + assert diag.get("prior") is False, "Initial state should not be viewing prior session" + + # 2. Verify _render_selectable_label exists in the App class + # This satisfies the requirement to check if it exists in the App class. + assert hasattr(App, '_render_selectable_label'), "App class must have _render_selectable_label method" + + # 3. Check performance to ensure stability + perf = client.get_performance() + metrics = perf.get("performance", {}) + # We check if FPS is reported; in some CI environments it might be low but should be > 0 if rendering + assert "fps" in metrics, "Performance metrics should include FPS" + + # 4. Basic smoke test: set and get a value to ensure GUI thread is responsive + # ai_response is a known field that is often rendered using selectable labels in various contexts + client.set_value("ai_response", "Test selectable text stability") + # Give it a few frames to process the task + time.sleep(1) + val = client.get_value("ai_response") + assert val == "Test selectable text stability", f"Expected 'Test selectable text stability', got '{val}'" + + # 5. Verify prior session indicator specifically via the gettable field + # prior_session_indicator is mapped to AppController.is_viewing_prior_session + prior_val = client.get_value("prior_session_indicator") + assert prior_val is False, "prior_session_indicator field should be False initially"