From 9ea7d4efccb0e4a57346088a9afc1a9fdd041a97 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 1 Jun 2026 19:20:33 -0400 Subject: [PATCH] feat(gui): Implement selectable thinking monologs and fix UI regressions - Implement [Pure]/[Read] toggle for AI thinking monologues to allow text selection/copying. - Fix TypeError: render_thinking_trace() missing 'entry_index' argument. - Fix [+] buttons in Discussion and Comms history by correctly updating window state registry. - Remove ListClipper from Discussion and Comms panels to fix variable-height clipping issues. - Increase clipping heights for large entries to improve visibility. - Fix code block scroll snapping in Markdown helper by robustifying text synchronization. --- conductor/tracks.md | 2 +- .../plan.md | 16 +- src/gui_2.py | 216 +++++++++--------- src/markdown_helper.py | 6 +- 4 files changed, 116 insertions(+), 124 deletions(-) diff --git a/conductor/tracks.md b/conductor/tracks.md index 1518a3ce..e50a555d 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -278,5 +278,5 @@ This file tracks all major tracks for the project. Each track has its own detail --- -- [ ] **Track: Selectable Thinking Monologs** +- [~] **Track: Selectable Thinking Monologs** *Link: [./tracks/selectable_thinking_monologs_20260601/](./tracks/selectable_thinking_monologs_20260601/)* diff --git a/conductor/tracks/selectable_thinking_monologs_20260601/plan.md b/conductor/tracks/selectable_thinking_monologs_20260601/plan.md index 4c32dfa2..6e5b147b 100644 --- a/conductor/tracks/selectable_thinking_monologs_20260601/plan.md +++ b/conductor/tracks/selectable_thinking_monologs_20260601/plan.md @@ -1,14 +1,14 @@ # Implementation Plan: Selectable Thinking Monologs ## Phase 1: UI Implementation -- [ ] Task: Update `render_thinking_trace` signature - - [ ] Modify `def render_thinking_trace(app: App, segments: list[dict], entry_index: int, is_standalone: bool = False)` to accept the parent `entry: dict`. - - [ ] Update all calls to `render_thinking_trace` in `src/gui_2.py` (e.g., in `render_discussion_entry` and `render_comms_history_panel`) to pass the appropriate `entry` object. For standalone traces where an entry dictionary might not exist, pass a dummy dict to hold the state. -- [ ] Task: Implement the UI Toggle - - [ ] Inside `render_thinking_trace`, below or next to the `imgui.collapsing_header`, add an `imgui.button` that toggles `entry.get("thinking_read_mode", True)`. -- [ ] Task: Implement Conditional Rendering - - [ ] If `thinking_read_mode` is `True` (Read mode), use `markdown_helper.render(content)` or `imgui.text_wrapped` for a clean reading view. - - [ ] If `thinking_read_mode` is `False` (Pure mode), use `render_selectable_label(app, f"think_text_...", content, multiline=True, height=-1)` to make the text selectable and copyable. +- [x] Task: Update `render_thinking_trace` signature + - [x] Modify `def render_thinking_trace(app: App, entry: dict, segments: list[dict], entry_index: int, is_standalone: bool = False)` to accept the parent `entry: dict`. + - [x] Update all calls to `render_thinking_trace` in `src/gui_2.py` (e.g., in `render_discussion_entry` and `render_comms_history_panel`) to pass the appropriate `entry` object. For standalone traces where an entry dictionary might not exist, pass a dummy dict to hold the state. +- [x] Task: Implement the UI Toggle + - [x] Inside `render_thinking_trace`, below or next to the `imgui.collapsing_header`, add an `imgui.button` that toggles `entry.get("thinking_read_mode", True)`. +- [x] Task: Implement Conditional Rendering + - [x] If `thinking_read_mode` is `True` (Read mode), use `markdown_helper.render(content)` or `imgui.text_wrapped` for a clean reading view. + - [x] If `thinking_read_mode` is `False` (Pure mode), use `render_selectable_label(app, f"think_text_...", content, multiline=True, height=-1)` to make the text selectable and copyable. ## Phase 2: Verification - [ ] Task: Verification diff --git a/src/gui_2.py b/src/gui_2.py index 5947bf94..80c53f3c 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -3424,7 +3424,7 @@ def render_discussion_entry(app: App, entry: dict, index: int) -> None: with imscope.id(f"disc_{index}"): collapsed, read_mode = entry.get("collapsed", False), entry.get("read_mode", False) if imgui.button("+" if collapsed else "-"): entry["collapsed"] = not collapsed - imgui.same_line(); render_text_viewer(app, f"Entry #{index+1}", entry["content"]); imgui.same_line(); imgui.set_next_item_width(120) + imgui.same_line(); render_text_viewer(app, f"Entry #{index+1}", entry["content"], id_suffix=f"disc_btn_{index}"); imgui.same_line(); imgui.set_next_item_width(120) if imgui.begin_combo("##role", entry["role"]): for r in app.disc_roles: if imgui.selectable(r, r == entry["role"])[0]: entry["role"] = r @@ -3461,7 +3461,7 @@ def render_discussion_entry(app: App, entry: dict, index: int) -> None: imgui.text_colored(vec4(160, 160, 150), preview) if not collapsed: thinking_segments, has_content = entry.get("thinking_segments", []), bool(entry.get("content", "").strip()) - if thinking_segments: render_thinking_trace(app, thinking_segments, index, is_standalone=not has_content) + if thinking_segments: render_thinking_trace(app, entry, thinking_segments, index, is_standalone=not has_content) if read_mode: render_discussion_entry_read_mode(app, entry, index) else: if not (bool(thinking_segments) and not has_content): ch, entry["content"] = imgui.input_text_multiline("##content", entry["content"], imgui.ImVec2(-1, 150)) @@ -3490,7 +3490,7 @@ def render_discussion_entry_read_mode(app: App, entry: dict, index: int) -> None with theme.ai_text_style(): markdown_helper.render(content, context_id=f'disc_{index}') else: - with imscope.child(f"read_content_{index}", size_y=150, flags=True): + with imscope.child(f"read_content_{index}", size_y=400, flags=True): if app.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) last_idx = 0 for m_idx, match in enumerate(matches): @@ -3650,82 +3650,78 @@ def render_comms_history_panel(app: App) -> None: log_to_render = app._comms_log_cache - clipper = imgui.ListClipper() - clipper.begin(len(log_to_render)) - while clipper.step(): - for i in range(clipper.display_start, clipper.display_end): - entry = log_to_render[i] - imgui.push_id(f"comms_entry_{i}") - - i_display = i + 1 - ts = entry.get("ts", "00:00:00") - direction = entry.get("direction", "??") - kind = entry.get("kind", entry.get("type", "??")) - provider = entry.get("provider", "?") - model = entry.get("model", "?") - tier = entry.get("source_tier", "main") - payload = entry.get("payload", {}) - if not payload and kind not in ("request", "response", "tool_call", "tool_result"): - payload = entry # legacy + for i, entry in enumerate(log_to_render): + imgui.push_id(f"comms_entry_{i}") + + i_display = i + 1 + ts = entry.get("ts", "00:00:00") + direction = entry.get("direction", "??") + kind = entry.get("kind", entry.get("type", "??")) + provider = entry.get("provider", "?") + model = entry.get("model", "?") + tier = entry.get("source_tier", "main") + payload = entry.get("payload", {}) + if not payload and kind not in ("request", "response", "tool_call", "tool_result"): + payload = entry # legacy - # Row 1: #Idx TS DIR KIND Provider/Model [Tier] - imgui.text_colored(C_LBL, f"#{i_display}"); imgui.same_line() - imgui.text_colored(vec4(160, 160, 160), ts) - - latency = entry.get("latency") or entry.get("metadata", {}).get("latency") - if latency: - imgui.same_line() - imgui.text_colored(C_SUB, f" ({latency:.2f}s)") - - ticket_id = entry.get("mma_ticket_id") - if ticket_id: - imgui.same_line() - imgui.text_colored(vec4(255, 120, 120), f"[{ticket_id}]") + # Row 1: #Idx TS DIR KIND Provider/Model [Tier] + imgui.text_colored(C_LBL, f"#{i_display}"); imgui.same_line() + imgui.text_colored(vec4(160, 160, 160), ts) + + latency = entry.get("latency") or entry.get("metadata", {}).get("latency") + if latency: imgui.same_line() - d_col = DIR_COLORS.get(direction, C_VAL) - imgui.text_colored(d_col, direction); imgui.same_line() - k_col = KIND_COLORS.get(kind, C_VAL) - imgui.text_colored(k_col, kind); imgui.same_line() - imgui.text_colored(C_LBL, f"{provider}/{model}"); imgui.same_line() - imgui.text_colored(C_SUB, f"[{tier}]") - - # Optimized content rendering using _render_heavy_text logic - idx_str = str(i) - if kind == "request": - usage = payload.get("usage", {}) - if usage: - inp = usage.get("input_tokens", 0) - imgui.text_colored(C_LBL, f" tokens in:{inp}") - render_heavy_text(app, "message", payload.get("message", ""), idx_str) - if payload.get("system"): - render_heavy_text(app, "system", payload.get("system", ""), idx_str) - elif kind == "response": - r = payload.get("round", 0) - sr = payload.get("stop_reason", "STOP") - usage = payload.get("usage", {}) - usage_str = "" - if usage: - inp = usage.get("input_tokens", 0) - out = usage.get("output_tokens", 0) - cache = usage.get("cache_read_input_tokens", 0) - usage_str = f" in:{inp} out:{out}" - if cache: usage_str += f" cache:{cache}" - imgui.text_colored(C_LBL, f"round: {r} stop_reason: {sr}{usage_str}") - - text_content = payload.get("text", "") - segments, parsed_response = thinking_parser.parse_thinking_trace(text_content) - if segments: render_thinking_trace(app, [{"content": s.content, "marker": s.marker} for s in segments], i, is_standalone=not bool(parsed_response.strip())) - if parsed_response: render_heavy_text(app, "text", parsed_response, idx_str) - - tcs = payload.get("tool_calls", []) - if tcs: render_heavy_text(app, "tool_calls", json.dumps(tcs, indent=1), idx_str) - - elif kind == "tool_call": render_heavy_text(app, payload.get("name", "call"), payload.get("script") or json.dumps(payload.get("args", {}), indent=1), idx_str) - elif kind == "tool_result": render_heavy_text(app, payload.get("name", "result"), payload.get("output", ""), idx_str) - else: render_heavy_text(app, "data", str(payload), idx_str) + imgui.text_colored(C_SUB, f" ({latency:.2f}s)") - imgui.separator() - imgui.pop_id() + ticket_id = entry.get("mma_ticket_id") + if ticket_id: + imgui.same_line() + imgui.text_colored(vec4(255, 120, 120), f"[{ticket_id}]") + imgui.same_line() + d_col = DIR_COLORS.get(direction, C_VAL) + imgui.text_colored(d_col, direction); imgui.same_line() + k_col = KIND_COLORS.get(kind, C_VAL) + imgui.text_colored(k_col, kind); imgui.same_line() + imgui.text_colored(C_LBL, f"{provider}/{model}"); imgui.same_line() + imgui.text_colored(C_SUB, f"[{tier}]") + + # Optimized content rendering using _render_heavy_text logic + idx_str = str(i) + if kind == "request": + usage = payload.get("usage", {}) + if usage: + inp = usage.get("input_tokens", 0) + imgui.text_colored(C_LBL, f" tokens in:{inp}") + render_heavy_text(app, "message", payload.get("message", ""), idx_str) + if payload.get("system"): + render_heavy_text(app, "system", payload.get("system", ""), idx_str) + elif kind == "response": + r = payload.get("round", 0) + sr = payload.get("stop_reason", "STOP") + usage = payload.get("usage", {}) + usage_str = "" + if usage: + inp = usage.get("input_tokens", 0) + out = usage.get("output_tokens", 0) + cache = usage.get("cache_read_input_tokens", 0) + usage_str = f" in:{inp} out:{out}" + if cache: usage_str += f" cache:{cache}" + imgui.text_colored(C_LBL, f"round: {r} stop_reason: {sr}{usage_str}") + + text_content = payload.get("text", "") + segments, parsed_response = thinking_parser.parse_thinking_trace(text_content) + if segments: render_thinking_trace(app, payload, [{"content": s.content, "marker": s.marker} for s in segments], i, is_standalone=not bool(parsed_response.strip())) + if parsed_response: render_heavy_text(app, "text", parsed_response, idx_str) + + tcs = payload.get("tool_calls", []) + if tcs: render_heavy_text(app, "tool_calls", json.dumps(tcs, indent=1), idx_str) + + elif kind == "tool_call": render_heavy_text(app, payload.get("name", "call"), payload.get("script") or json.dumps(payload.get("args", {}), indent=1), idx_str) + elif kind == "tool_result": render_heavy_text(app, payload.get("name", "result"), payload.get("output", ""), idx_str) + else: render_heavy_text(app, "data", str(payload), idx_str) + + imgui.separator() + imgui.pop_id() if app._scroll_comms_to_bottom: imgui.set_scroll_here_y(1.0) @@ -3799,11 +3795,8 @@ def render_discussion_entries(app: App) -> None: if tier_usage: persona_name = tier_usage.get("persona") if persona_name: display_entries = [e for e in app.disc_entries if e.get("role") == persona_name or e.get("role") == "User"] - clipper = imgui.ListClipper(); clipper.begin(len(display_entries)) - while clipper.step(): - for i in range(clipper.display_start, clipper.display_end): - if i < len(display_entries): - render_discussion_entry(app, display_entries[i], i) + for i, entry in enumerate(display_entries): + render_discussion_entry(app, display_entries[i], i) if app._scroll_disc_to_bottom: imgui.set_scroll_here_y(1.0); app._scroll_disc_to_bottom = False def render_discussion_entry_controls(app: App) -> None: @@ -4077,7 +4070,7 @@ def render_response_panel(app: App) -> None: with theme.ai_text_style(): segments, parsed_response = thinking_parser.parse_thinking_trace(app.ai_response) if segments: - render_thinking_trace(app, [{"content": s.content, "marker": s.marker} for s in segments], 9999) + render_thinking_trace(app, {}, [{"content": s.content, "marker": s.marker} for s in segments], 9999) markdown_helper.render(parsed_response, context_id="response") imgui.separator() @@ -4620,61 +4613,58 @@ def render_error_tint(app: App) -> None: imgui.text_colored(imgui.ImVec4(1, 0, 0, 1), "HOT RELOAD ERROR") imgui.text_wrapped(HotReloader.last_error or "Unknown error") -def render_text_viewer(app: App, label: str, content: str, text_type: str = 'text', force_open: bool = False) -> None: - if imgui.button("[+]##" + str(id(content))) or force_open: +def render_text_viewer(app: App, label: str, content: str, text_type: str = 'text', force_open: bool = False, id_suffix: str = "") -> None: + if imgui.button(f"[+]##{id_suffix or str(id(content))}") or force_open: app.text_viewer_type = text_type app.show_text_viewer = True app.text_viewer_title = label app.text_viewer_content = content + app.show_windows["Text Viewer"] = True def render_heavy_text(app: App, label: str, content: str, id_suffix: str = "") -> None: - imgui.text_colored(C_LBL, f"{label}:") - imgui.same_line() - if imgui.button("[+]##" + label + id_suffix): + if imgui.button(f"[+]##{label}{id_suffix}"): app.show_text_viewer = True + app.show_windows["Text Viewer"] = True app.text_viewer_type = 'markdown' if label in ('message', 'text', 'content', 'system') else 'json' if label in ('tool_calls', 'data') else 'powershell' if label == 'script' else 'text' app.text_viewer_title = label app.text_viewer_content = content + imgui.same_line() + imgui.text_colored(C_LBL, f"{label}:"); imgui.same_line() + render_selectable_label(app, f"heavy_label_{label}_{id_suffix}", content[:60].replace("\n", " ") + ("..." if len(content)>60 else ""), color=C_VAL) - if not content: - imgui.text_disabled("(empty)") - return - - is_md = label in ("message", "text", "content") - ctx_id = f"heavy_{label}_{id_suffix}" - - with theme.ai_text_style(): - if len(content) > COMMS_CLAMP_CHARS: + if content: + ctx_id = f"{label}_{id_suffix}" + is_md = label in ('message', 'text', 'content', 'system') + with imscope.indent(): if is_md: - imgui.begin_child(f"heavy_text_child_{label}_{id_suffix}", imgui.ImVec2(0, 180), True, imgui.WindowFlags_.always_vertical_scrollbar) + imgui.begin_child(f"heavy_text_child_{label}_{id_suffix}", imgui.ImVec2(0, 300), True, imgui.WindowFlags_.always_vertical_scrollbar) markdown_helper.render(content, context_id=ctx_id) imgui.end_child() else: - imgui.input_text_multiline(f"##heavy_text_input_{label}_{id_suffix}", content, imgui.ImVec2(-1, 180), imgui.InputTextFlags_.read_only) - else: - if is_md: - markdown_helper.render(content, context_id=ctx_id) - else: + imgui.begin_child(f"heavy_text_child_{label}_{id_suffix}", imgui.ImVec2(0, 200), True, imgui.WindowFlags_.always_vertical_scrollbar) if app.ui_word_wrap: - imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(content) - imgui.pop_text_wrap_pos() + with imscope.text_wrap(imgui.get_content_region_avail().x): + imgui.text(content) else: imgui.text(content) + imgui.end_child() -def render_thinking_trace(app: App, segments: list[dict], entry_index: int, is_standalone: bool = False) -> None: +def render_thinking_trace(app: App, entry: dict, segments: list[dict], entry_index: int, is_standalone: bool = False) -> None: if not segments: return with imscope.style_color(imgui.Col_.child_bg, vec4(40, 35, 25, 180)), \ theme.ai_text_style(): imgui.indent() - show_content = True if not is_standalone: header_label = f"Monologue ({len(segments)} traces)###thinking_header_{entry_index}" show_content = imgui.collapsing_header(header_label) - if show_content: + thinking_read_mode = entry.get("thinking_read_mode", True) + if imgui.button(f"[Pure]##think_pure_{entry_index}" if thinking_read_mode else f"[Read]##think_read_{entry_index}"): + entry["thinking_read_mode"] = not thinking_read_mode + imgui.same_line() + imgui.text_colored(vec4(180, 150, 80), "Selectable toggle") h = 150 if is_standalone else 100 with imscope.child(f"thinking_content_{entry_index}", 0, h, True): for idx, seg in enumerate(segments): @@ -4682,13 +4672,15 @@ def render_thinking_trace(app: App, segments: list[dict], entry_index: int, is_s marker = seg.get("marker", "thinking") with imscope.id(f"think_{entry_index}_{idx}"): imgui.text_colored(vec4(180, 150, 80), f"[{marker}]") - if app.ui_word_wrap: - with imscope.text_wrap(imgui.get_content_region_avail().x): + if thinking_read_mode: + if app.ui_word_wrap: + with imscope.text_wrap(imgui.get_content_region_avail().x): + imgui.text(content) + else: imgui.text(content) else: - imgui.text(content) + render_selectable_label(app, f"think_text_{entry_index}_{idx}", content, multiline=True, height=-1) imgui.separator() - imgui.unindent() def render_selectable_label(app: App, label: str, value: str, width: float = 0.0, multiline: bool = False, height: float = 0.0, color: Optional[imgui.ImVec4] = None) -> None: diff --git a/src/markdown_helper.py b/src/markdown_helper.py index d4cb460e..ebb9c845 100644 --- a/src/markdown_helper.py +++ b/src/markdown_helper.py @@ -126,13 +126,13 @@ class MarkdownRenderer: # Sync text and language lang_id = self._lang_map.get(lang_tag, ed.TextEditor.LanguageDefinitionId.none) - target_text = code + "\n" - if editor.get_text() != target_text: + # Robust check to avoid re-setting text every frame (which resets scroll) + curr_text = editor.get_text().replace('\r\n', '\n').strip() + if curr_text != code.replace('\r\n', '\n').strip(): editor.set_text(code) editor.set_language_definition(lang_id) elif editor.get_language_definition_name().lower() != lang_tag: - # get_language_definition_name might not match exactly but good enough check editor.set_language_definition(lang_id) # Dynamic height calculation