feat(ui): Implement selectable text across primary GUI panels
This commit is contained in:
114
src/gui_2.py
114
src/gui_2.py
@@ -211,33 +211,34 @@ class App:
|
|||||||
if len(content) > COMMS_CLAMP_CHARS:
|
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
|
# 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.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()
|
imgui.end_child()
|
||||||
else:
|
else:
|
||||||
if self.ui_word_wrap:
|
self._render_selectable_label(f'heavy_val_{label}_{hash(content)}', content, width=-1, multiline=self.ui_word_wrap, height=0)
|
||||||
imgui.push_text_wrap_pos(0.0)
|
|
||||||
imgui.text_unformatted(content)
|
|
||||||
imgui.pop_text_wrap_pos()
|
|
||||||
else:
|
|
||||||
imgui.text_unformatted(content)
|
|
||||||
# ---------------------------------------------------------------- gui
|
# ---------------------------------------------------------------- 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:
|
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)))
|
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, 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))
|
imgui.push_style_color(imgui.Col_.border, vec4(0, 0, 0, 0))
|
||||||
if color:
|
if color:
|
||||||
imgui.push_style_color(imgui.Col_.text, color)
|
imgui.push_style_color(imgui.Col_.text, color)
|
||||||
pops += 1
|
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:
|
if multiline:
|
||||||
imgui.input_text_multiline("##" + label, value, imgui.ImVec2(width, height), imgui.InputTextFlags_.read_only)
|
imgui.input_text_multiline("##" + label, value, imgui.ImVec2(width, height), imgui.InputTextFlags_.read_only)
|
||||||
else:
|
else:
|
||||||
if width > 0: imgui.set_next_item_width(width)
|
if width > 0: imgui.set_next_item_width(width)
|
||||||
imgui.input_text("##" + label, value, imgui.InputTextFlags_.read_only)
|
imgui.input_text("##" + label, value, imgui.InputTextFlags_.read_only)
|
||||||
imgui.pop_style_color(pops)
|
imgui.pop_style_color(pops)
|
||||||
|
imgui.pop_style_var(2)
|
||||||
imgui.pop_id()
|
imgui.pop_id()
|
||||||
|
|
||||||
def _show_menus(self) -> None:
|
def _show_menus(self) -> None:
|
||||||
if imgui.begin_menu("manual slop"):
|
if imgui.begin_menu("manual slop"):
|
||||||
if imgui.menu_item("Quit", "Ctrl+Q", False)[0]:
|
if imgui.menu_item("Quit", "Ctrl+Q", False)[0]:
|
||||||
@@ -1305,14 +1306,7 @@ class App:
|
|||||||
if len(content) > 80: preview += "..."
|
if len(content) > 80: preview += "..."
|
||||||
imgui.text_colored(vec4(180, 180, 180), preview)
|
imgui.text_colored(vec4(180, 180, 180), preview)
|
||||||
else:
|
else:
|
||||||
imgui.begin_child(f"prior_content_{idx}", imgui.ImVec2(0, 150), True)
|
self._render_selectable_label(f'prior_content_val_{idx}', content, width=-1, multiline=True, height=150)
|
||||||
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()
|
|
||||||
|
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
imgui.pop_id()
|
imgui.pop_id()
|
||||||
@@ -1349,7 +1343,7 @@ class App:
|
|||||||
last_updated = disc_data.get("last_updated", "")
|
last_updated = disc_data.get("last_updated", "")
|
||||||
imgui.text_colored(C_LBL, "commit:")
|
imgui.text_colored(C_LBL, "commit:")
|
||||||
imgui.same_line()
|
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()
|
imgui.same_line()
|
||||||
if imgui.button("Update Commit"):
|
if imgui.button("Update Commit"):
|
||||||
git_dir = self.ui_project_git_dir
|
git_dir = self.ui_project_git_dir
|
||||||
@@ -1467,37 +1461,41 @@ class App:
|
|||||||
imgui.text_colored(vec4(160, 160, 150), preview)
|
imgui.text_colored(vec4(160, 160, 150), preview)
|
||||||
if not collapsed:
|
if not collapsed:
|
||||||
if read_mode:
|
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"]
|
content = entry["content"]
|
||||||
last_idx = 0
|
|
||||||
pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?")
|
pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?")
|
||||||
for match in pattern.finditer(content):
|
matches = list(pattern.finditer(content))
|
||||||
before = content[last_idx:match.start()]
|
if not matches:
|
||||||
if before: imgui.text(before)
|
self._render_selectable_label(f'read_content_{i}', content, width=-1, multiline=True, height=150)
|
||||||
header_text = match.group(0).split("\n")[0].strip()
|
else:
|
||||||
path = match.group(2)
|
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
||||||
code_block = match.group(4)
|
if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||||
if imgui.collapsing_header(header_text):
|
last_idx = 0
|
||||||
if imgui.button(f"[Source]##{i}_{match.start()}"):
|
for m_idx, match in enumerate(matches):
|
||||||
res = mcp_client.read_file(path)
|
before = content[last_idx:match.start()]
|
||||||
if res:
|
if before: self._render_selectable_label(f'read_before_{i}_{m_idx}', before, width=-1, multiline=True, height=0)
|
||||||
self.text_viewer_title = path
|
header_text = match.group(0).split("\n")[0].strip()
|
||||||
self.text_viewer_content = res
|
path = match.group(2)
|
||||||
self.show_text_viewer = True
|
code_block = match.group(4)
|
||||||
if code_block:
|
if imgui.collapsing_header(header_text):
|
||||||
code_content = code_block.strip()
|
if imgui.button(f"[Source]##{i}_{match.start()}"):
|
||||||
if code_content.count("\n") + 1 > 50:
|
res = mcp_client.read_file(path)
|
||||||
imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True)
|
if res:
|
||||||
imgui.text(code_content)
|
self.text_viewer_title = path
|
||||||
imgui.end_child()
|
self.text_viewer_content = res
|
||||||
else:
|
self.show_text_viewer = True
|
||||||
imgui.text(code_content)
|
if code_block:
|
||||||
last_idx = match.end()
|
code_content = code_block.strip()
|
||||||
after = content[last_idx:]
|
if code_content.count("\n") + 1 > 50:
|
||||||
if after: imgui.text(after)
|
imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True)
|
||||||
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
imgui.text(code_content)
|
||||||
imgui.end_child()
|
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:
|
else:
|
||||||
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
|
ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150))
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
@@ -1536,7 +1534,7 @@ class App:
|
|||||||
sid = "None"
|
sid = "None"
|
||||||
if hasattr(ai_client, "_gemini_cli_adapter") and ai_client._gemini_cli_adapter:
|
if hasattr(ai_client, "_gemini_cli_adapter") and ai_client._gemini_cli_adapter:
|
||||||
sid = ai_client._gemini_cli_adapter.session_id or "None"
|
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"):
|
if imgui.button("Reset CLI Session"):
|
||||||
ai_client.reset_session()
|
ai_client.reset_session()
|
||||||
imgui.text("Binary Path")
|
imgui.text("Binary Path")
|
||||||
@@ -1560,7 +1558,7 @@ class App:
|
|||||||
total = usage["input_tokens"] + usage["output_tokens"]
|
total = usage["input_tokens"] + usage["output_tokens"]
|
||||||
if total == 0 and usage.get("total_tokens", 0) > 0:
|
if total == 0 and usage.get("total_tokens", 0) > 0:
|
||||||
total = usage["total_tokens"]
|
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:
|
if usage.get("last_latency", 0.0) > 0:
|
||||||
imgui.text_colored(C_LBL, f" Last Latency: {usage['last_latency']:.2f}s")
|
imgui.text_colored(C_LBL, f" Last Latency: {usage['last_latency']:.2f}s")
|
||||||
if usage["cache_read_input_tokens"]:
|
if usage["cache_read_input_tokens"]:
|
||||||
@@ -1623,13 +1621,13 @@ class App:
|
|||||||
tokens = in_t + out_t
|
tokens = in_t + out_t
|
||||||
cost = cost_tracker.estimate_cost(model, in_t, out_t)
|
cost = cost_tracker.estimate_cost(model, in_t, out_t)
|
||||||
imgui.table_next_row()
|
imgui.table_next_row()
|
||||||
imgui.table_set_column_index(0); imgui.text(tier)
|
imgui.table_set_column_index(0); self._render_selectable_label(f"tier_{tier}", tier, width=-1)
|
||||||
imgui.table_set_column_index(1); imgui.text(model.split('-')[0])
|
imgui.table_set_column_index(1); self._render_selectable_label(f"model_{tier}", model.split("-")[0], width=-1)
|
||||||
imgui.table_set_column_index(2); imgui.text(f"{tokens:,}")
|
imgui.table_set_column_index(2); self._render_selectable_label(f"tokens_{tier}", f"{tokens:,}", width=-1)
|
||||||
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(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()
|
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())
|
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:
|
else:
|
||||||
imgui.text_disabled("No MMA tier usage data")
|
imgui.text_disabled("No MMA tier usage data")
|
||||||
if stats.get("would_trim"):
|
if stats.get("would_trim"):
|
||||||
@@ -1974,7 +1972,8 @@ class App:
|
|||||||
|
|
||||||
script_preview = script.replace("\n", " ")[:150]
|
script_preview = script.replace("\n", " ")[:150]
|
||||||
if len(script) > 150: script_preview += "..."
|
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_title = f"Tool Call #{i+1} Details"
|
||||||
self.text_viewer_content = combined
|
self.text_viewer_content = combined
|
||||||
self.show_text_viewer = True
|
self.show_text_viewer = True
|
||||||
@@ -1982,7 +1981,8 @@ class App:
|
|||||||
imgui.table_next_column()
|
imgui.table_next_column()
|
||||||
res_preview = res.replace("\n", " ")[:30]
|
res_preview = res.replace("\n", " ")[:30]
|
||||||
if len(res) > 30: res_preview += "..."
|
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_title = f"Tool Call #{i+1} Details"
|
||||||
self.text_viewer_content = combined
|
self.text_viewer_content = combined
|
||||||
self.show_text_viewer = True
|
self.show_text_viewer = True
|
||||||
@@ -2648,7 +2648,7 @@ class App:
|
|||||||
if stream_key is not None:
|
if stream_key is not None:
|
||||||
content = self.mma_streams.get(stream_key, "")
|
content = self.mma_streams.get(stream_key, "")
|
||||||
imgui.begin_child(f"##stream_content_{tier_key}", imgui.ImVec2(-1, -1))
|
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:
|
try:
|
||||||
if len(content) != self._tier_stream_last_len.get(stream_key, -1):
|
if len(content) != self._tier_stream_last_len.get(stream_key, -1):
|
||||||
imgui.set_scroll_here_y(1.0)
|
imgui.set_scroll_here_y(1.0)
|
||||||
@@ -2674,7 +2674,7 @@ class App:
|
|||||||
else:
|
else:
|
||||||
imgui.text(f"{ticket_id} [{status}]")
|
imgui.text(f"{ticket_id} [{status}]")
|
||||||
imgui.begin_child(f"##tier3_{ticket_id}_scroll", imgui.ImVec2(-1, 150), True)
|
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:
|
try:
|
||||||
if len(self.mma_streams[key]) != self._tier_stream_last_len.get(key, -1):
|
if len(self.mma_streams[key]) != self._tier_stream_last_len.get(key, -1):
|
||||||
imgui.set_scroll_here_y(1.0)
|
imgui.set_scroll_here_y(1.0)
|
||||||
|
|||||||
47
tests/test_selectable_ui.py
Normal file
47
tests/test_selectable_ui.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user