diff --git a/conductor/tracks/gui_refactor_stabilization_20260512/plan_context_refactor.md b/conductor/tracks/gui_refactor_stabilization_20260512/plan_context_refactor.md new file mode 100644 index 0000000..ceca301 --- /dev/null +++ b/conductor/tracks/gui_refactor_stabilization_20260512/plan_context_refactor.md @@ -0,0 +1,30 @@ +# Implementation Plan: Modular Context Composition UI + +## Objective +Refactor the monolithic `_render_context_composition_panel` in `src/gui_2.py` into smaller, semantic methods to improve readability, maintainability, and reduce the complexity of the main GUI orchestrator. + +## Key Files & Context +- `src/gui_2.py`: The target for refactoring. +- `src/imgui_scopes.py`: Used for scoped ImGui blocks. + +## Implementation Steps + +### Phase 1: Infrastructure & Background Tasks +- [ ] Task: Extract file stats background worker logic into `_update_context_file_stats()`. +- [ ] Task: In `_render_context_composition_panel`, ensure state variables (`_file_stats_cache`, etc.) are initialized once. + +### Phase 2: Extract Sub-Panels +- [ ] Task: Extract the batch action bar logic into `_render_context_batch_actions()`. +- [ ] Task: Extract the grouped files tree table logic into `_render_context_files_table()`. +- [ ] Task: Extract the screenshots section into `_render_context_screenshots()`. +- [ ] Task: Extract the context presets section into `_render_context_presets()`. + +### Phase 3: Assembly & Verification +- [ ] Task: Reassemble `_render_context_composition_panel` by calling the new sub-methods. +- [ ] Task: Run the custom AST linter to ensure all scopes are correctly closed. +- [ ] Task: Run fast render tests to verify no regressions in the context panel. + +## Verification & Testing +- **AST Linting**: `uv run python scripts/check_imgui_scopes.py src/gui_2.py` +- **Fast Render Tests**: `uv run pytest tests/test_gui_fast_render.py` +- **Manual Verification**: Open the Context Composition panel, verify batch actions work, files are correctly grouped and listed, and presets can be saved/loaded. diff --git a/src/gui_2.py b/src/gui_2.py index e5b3229..3c45cd5 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -3104,238 +3104,243 @@ class App: else: imgui.text_disabled("Message & Response panels are detached.") - def _render_context_composition_panel(self) -> None: + def _update_context_file_stats(self) -> tuple[int, int]: if not hasattr(self, '_file_stats_cache'): self._file_stats_cache = {} if not hasattr(self, '_file_stats_queue'): self._file_stats_queue = [] if not hasattr(self, '_file_stats_worker_active'): self._file_stats_worker_active = False - - if imgui.collapsing_header("Context Composition##panel"): - total_lines = 0 - total_ast = 0 - - missing_keys = [] + + total_lines = 0 + total_ast = 0 + + missing_keys = [] + for f in self.context_files: + f_path = f.path if hasattr(f, "path") else str(f) + mtime = os.path.getmtime(f_path) if os.path.exists(f_path) else 0 + cache_key = f"{f_path}_{mtime}" + if cache_key not in self._file_stats_cache: + missing_keys.append((f_path, cache_key)) + else: + stats = self._file_stats_cache[cache_key] + total_lines += stats.get("lines", 0) + total_ast += stats.get("ast_elements", 0) + + if missing_keys and not self._file_stats_worker_active: + def _stats_worker(): + self._file_stats_worker_active = True + try: + for path, key in missing_keys[:10]: + self._file_stats_cache[key] = aggregate.compute_file_stats(path) + finally: + self._file_stats_worker_active = False + + threading.Thread(target=_stats_worker, daemon=True).start() + + return total_lines, total_ast + + def _render_context_batch_actions(self, total_lines: int, total_ast: int) -> None: + imgui.text("Batch:") + for mode in ["full", "summary", "skeleton", "outline", "masked", "none"]: + if imgui.button(f"{mode.capitalize()}##batch"): + for f in self.context_files: + f_path = f.path if hasattr(f, "path") else str(f) + if f_path in self.ui_selected_context_files: + f.view_mode = mode + imgui.same_line() + if imgui.button("Sel All##selall"): for f in self.context_files: f_path = f.path if hasattr(f, "path") else str(f) - mtime = os.path.getmtime(f_path) if os.path.exists(f_path) else 0 - cache_key = f"{f_path}_{mtime}" - if cache_key not in self._file_stats_cache: - missing_keys.append((f_path, cache_key)) - else: - stats = self._file_stats_cache[cache_key] - total_lines += stats.get("lines", 0) - total_ast += stats.get("ast_elements", 0) - - # Process one missing key per frame or spawn a worker - if missing_keys and not self._file_stats_worker_active: - def _stats_worker(): - self._file_stats_worker_active = True - try: - import threading - for path, key in missing_keys[:10]: # Process small batches - self._file_stats_cache[key] = aggregate.compute_file_stats(path) - finally: - self._file_stats_worker_active = False - - threading.Thread(target=_stats_worker, daemon=True).start() - - #region: Batch Action Bar imgui.text("Batch:") - # imgui.same_line() - for mode in ["full", "summary", "skeleton", "outline", "masked", "none"]: - if imgui.button(f"{mode.capitalize()}##batch"): - for f in self.context_files: - f_path = f.path if hasattr(f, "path") else str(f) - if f_path in self.ui_selected_context_files: - f.view_mode = mode - imgui.same_line() - if imgui.button("Sel All##selall"): - for f in self.context_files: - f_path = f.path if hasattr(f, "path") else str(f) - self.ui_selected_context_files.add(f_path) - imgui.same_line() - if imgui.button("Unsel All##unselall"): - self.ui_selected_context_files.clear() - imgui.same_line() - if imgui.button("Add Files"): - imgui.open_popup("Select Context Files") - imgui.same_line() - if imgui.button("Add All##addall"): - import copy - context_paths = {f.path if hasattr(f, "path") else str(f) for f in self.context_files} - for f in self.files: - f_path = f.path if hasattr(f, "path") else str(f) - if f_path not in context_paths: - f_copy = copy.deepcopy(f) - self.context_files.append(f_copy) - self._populate_auto_slices(f_copy) - imgui.same_line() - if imgui.button("Del##batch"): - new_files = [] - for f in self.context_files: - f_path = f.path if hasattr(f, "path") else str(f) - if f_path not in self.ui_selected_context_files: - new_files.append(f) - self.context_files = new_files - self.ui_selected_context_files.clear() - imgui.same_line() - imgui.text(f" | Total: {len(self.context_files)} files, {total_lines} lines, {total_ast} AST elements") - #endregion: Batch Action Bar - - imgui.dummy(imgui.ImVec2(0, 4)) - - grouped_files = aggregate.group_files_by_dir(self.context_files) - - with imscope.table("ctx_comp_table", 2, imgui.TableFlags_.resizable | imgui.TableFlags_.borders) as active: - if active: - imgui.table_setup_column("File", imgui.TableColumnFlags_.width_stretch) - imgui.table_setup_column("Flags", imgui.TableColumnFlags_.width_fixed, 200) - imgui.table_headers_row() - - file_indices = {id(f): idx for idx, f in enumerate(self.context_files)} - - for dir_name, g_files in grouped_files.items(): - imgui.table_next_row() - imgui.table_set_column_index(0) - with imscope.tree_node_ex(f"{dir_name}##dir_{dir_name}", imgui.TreeNodeFlags_.default_open) as is_open: - imgui.table_set_column_index(1) - if is_open: - for f_item in g_files: - i = file_indices[id(f_item)] - imgui.table_next_row() - imgui.table_set_column_index(0) - - # Checkbox for selection - f_path = f_item.path if hasattr(f_item, "path") else str(f_item) - is_sel = f_path in self.ui_selected_context_files - changed_sel, is_sel = imgui.checkbox(f"##sel{i}", is_sel) - if changed_sel: - if imgui.get_io().key_shift and self._last_selected_context_index != -1: - start = min(self._last_selected_context_index, i) - end = max(self._last_selected_context_index, i) - for idx in range(start, end + 1): - item = self.context_files[idx] - item_path = item.path if hasattr(item, "path") else str(item) - if is_sel: - self.ui_selected_context_files.add(item_path) - else: - self.ui_selected_context_files.discard(item_path) - else: + self.ui_selected_context_files.add(f_path) + imgui.same_line() + if imgui.button("Unsel All##unselall"): + self.ui_selected_context_files.clear() + imgui.same_line() + if imgui.button("Add Files"): + imgui.open_popup("Select Context Files") + imgui.same_line() + if imgui.button("Add All##addall"): + import copy + context_paths = {f.path if hasattr(f, "path") else str(f) for f in self.context_files} + for f in self.files: + f_path = f.path if hasattr(f, "path") else str(f) + if f_path not in context_paths: + f_copy = copy.deepcopy(f) + self.context_files.append(f_copy) + self._populate_auto_slices(f_copy) + imgui.same_line() + if imgui.button("Del##batch"): + new_files = [] + for f in self.context_files: + f_path = f.path if hasattr(f, "path") else str(f) + if f_path not in self.ui_selected_context_files: + new_files.append(f) + self.context_files = new_files + self.ui_selected_context_files.clear() + imgui.same_line() + imgui.text(f" | Total: {len(self.context_files)} files, {total_lines} lines, {total_ast} AST elements") + + def _render_context_files_table(self) -> None: + imgui.dummy(imgui.ImVec2(0, 4)) + grouped_files = aggregate.group_files_by_dir(self.context_files) + + with imscope.table("ctx_comp_table", 2, imgui.TableFlags_.resizable | imgui.TableFlags_.borders) as active: + if active: + imgui.table_setup_column("File", imgui.TableColumnFlags_.width_stretch) + imgui.table_setup_column("Flags", imgui.TableColumnFlags_.width_fixed, 200) + imgui.table_headers_row() + + file_indices = {id(f): idx for idx, f in enumerate(self.context_files)} + + for dir_name, g_files in grouped_files.items(): + imgui.table_next_row() + imgui.table_set_column_index(0) + with imscope.tree_node_ex(f"{dir_name}##dir_{dir_name}", imgui.TreeNodeFlags_.default_open) as is_open: + imgui.table_set_column_index(1) + if is_open: + for f_item in g_files: + i = file_indices[id(f_item)] + imgui.table_next_row() + imgui.table_set_column_index(0) + + f_path = f_item.path if hasattr(f_item, "path") else str(f_item) + is_sel = f_path in self.ui_selected_context_files + changed_sel, is_sel = imgui.checkbox(f"##sel{i}", is_sel) + if changed_sel: + if imgui.get_io().key_shift and self._last_selected_context_index != -1: + start = min(self._last_selected_context_index, i) + end = max(self._last_selected_context_index, i) + for idx in range(start, end + 1): + item = self.context_files[idx] + item_path = item.path if hasattr(item, "path") else str(item) if is_sel: - self.ui_selected_context_files.add(f_path) + self.ui_selected_context_files.add(item_path) else: - self.ui_selected_context_files.discard(f_path) - self._last_selected_context_index = i - imgui.same_line() - - mtime = os.path.getmtime(f_path) if os.path.exists(f_path) else 0 - cache_key = f"{f_path}_{mtime}" - stats = self._file_stats_cache.get(cache_key, {"lines": 0, "ast_elements": 0}) - f_name = os.path.basename(f_path) - imgui.text(f"{f_name} (L: {stats.get('lines', 0)}, AST: {stats.get('ast_elements', 0)})") + self.ui_selected_context_files.discard(item_path) + else: + if is_sel: + self.ui_selected_context_files.add(f_path) + else: + self.ui_selected_context_files.discard(f_path) + self._last_selected_context_index = i + imgui.same_line() - if f_path.lower().endswith(('.c', '.cpp', '.h', '.hpp', '.cxx', '.cc')): - imgui.same_line() - if imgui.button(f"[Inspect]##{i}"): - self.ui_inspecting_ast_file = f_item - self._show_ast_inspector = True - - imgui.same_line() - if imgui.button(f"[Slices]##{i}"): - self.ui_editing_slices_file = f_item - f_path = f_item.path if hasattr(f_item, "path") else str(f_item) - self.text_viewer_title = f"Slices: {f_path}" - try: - self.text_viewer_content = mcp_client.read_file(f_path) - except Exception as e: - self.text_viewer_content = f"Error reading file: {e}" - self.text_viewer_type = 'cpp' if f_path.endswith(('.cpp', '.hpp', '.h')) else 'python' if f_path.endswith('.py') else 'text' - self.show_text_viewer = True + mtime = os.path.getmtime(f_path) if os.path.exists(f_path) else 0 + cache_key = f"{f_path}_{mtime}" + stats = self._file_stats_cache.get(cache_key, {"lines": 0, "ast_elements": 0}) + f_name = os.path.basename(f_path) + imgui.text(f"{f_name} (L: {stats.get('lines', 0)}, AST: {stats.get('ast_elements', 0)})") - imgui.table_set_column_index(1) - if not hasattr(f_item, "view_mode"): - f_item.view_mode = "summary" - view_modes = ["full", "summary", "skeleton", "outline", "masked", "none"] + if f_path.lower().endswith(('.c', '.cpp', '.h', '.hpp', '.cxx', '.cc')): + imgui.same_line() + if imgui.button(f"[Inspect]##{i}"): + self.ui_inspecting_ast_file = f_item + self._show_ast_inspector = True + + imgui.same_line() + if imgui.button(f"[Slices]##{i}"): + self.ui_editing_slices_file = f_item + f_path = f_item.path if hasattr(f_item, "path") else str(f_item) + self.text_viewer_title = f"Slices: {f_path}" try: - current_idx = view_modes.index(f_item.view_mode) - except ValueError: - current_idx = 1 - f_item.view_mode = "summary" - imgui.set_next_item_width(120) - changed_vm, new_idx = imgui.combo(f"##vm{i}", current_idx, view_modes) - if changed_vm: - f_item.view_mode = view_modes[new_idx] - - imgui.same_line() - if imgui.button(f"[Save]##vpsave{i}"): - imgui.open_popup(f"save_vp_popup{i}") - - if imgui.begin_popup(f"save_vp_popup{i}"): - imgui.text("Preset Name:") - changed_pname, self.ui_new_vp_name = imgui.input_text(f"##pname{i}", self.ui_new_vp_name) - if imgui.button("OK"): - if self.ui_new_vp_name.strip(): - self.controller._cb_save_view_preset(self.ui_new_vp_name.strip(), f_item) - self.ui_new_vp_name = "" - imgui.close_current_popup() - imgui.end_popup() + self.text_viewer_content = mcp_client.read_file(f_path) + except Exception as e: + self.text_viewer_content = f"Error reading file: {e}" + self.text_viewer_type = 'cpp' if f_path.endswith(('.cpp', '.hpp', '.h')) else 'python' if f_path.endswith('.py') else 'text' + self.show_text_viewer = True + imgui.table_set_column_index(1) + if not hasattr(f_item, "view_mode"): + f_item.view_mode = "summary" + view_modes = ["full", "summary", "skeleton", "outline", "masked", "none"] + try: + current_idx = view_modes.index(f_item.view_mode) + except ValueError: + current_idx = 1 + f_item.view_mode = "summary" + imgui.set_next_item_width(120) + changed_vm, new_idx = imgui.combo(f"##vm{i}", current_idx, view_modes) + if changed_vm: + f_item.view_mode = view_modes[new_idx] + + imgui.same_line() + if imgui.button(f"[Save]##vpsave{i}"): + imgui.open_popup(f"save_vp_popup{i}") + + if imgui.begin_popup(f"save_vp_popup{i}"): + imgui.text("Preset Name:") + changed_pname, self.ui_new_vp_name = imgui.input_text(f"##pname{i}", self.ui_new_vp_name) + if imgui.button("OK"): + if self.ui_new_vp_name.strip(): + self.controller._cb_save_view_preset(self.ui_new_vp_name.strip(), f_item) + self.ui_new_vp_name = "" + imgui.close_current_popup() + imgui.end_popup() + + imgui.same_line() + if imgui.button(f"[Load]##vpload{i}"): + imgui.open_popup(f"load_vp_popup{i}") + + if imgui.begin_popup(f"load_vp_popup{i}"): + vp_names = sorted([vp.name for vp in self.controller.view_presets]) + if not vp_names: + imgui.text("No presets saved.") + for vp_name in vp_names: + if imgui.selectable(vp_name): + self.controller._cb_apply_view_preset(vp_name, f_item) + imgui.close_current_popup() + imgui.end_popup() + if hasattr(f_item, "custom_slices") and f_item.custom_slices: imgui.same_line() - if imgui.button(f"[Load]##vpload{i}"): - imgui.open_popup(f"load_vp_popup{i}") - - if imgui.begin_popup(f"load_vp_popup{i}"): - vp_names = sorted([vp.name for vp in self.controller.view_presets]) - if not vp_names: - imgui.text("No presets saved.") - for vp_name in vp_names: - if imgui.selectable(vp_name): - self.controller._cb_apply_view_preset(vp_name, f_item) - imgui.close_current_popup() - imgui.end_popup() - if hasattr(f_item, "custom_slices") and f_item.custom_slices: - imgui.same_line() - imgui.text_colored(imgui.ImVec4(1.0, 0.5, 0.0, 1.0), "[Slices Active]") - # Context Composition collasping header + imgui.text_colored(imgui.ImVec4(1.0, 0.5, 0.0, 1.0), "[Slices Active]") + + def _render_context_screenshots(self) -> None: + for i, s in enumerate(self.screenshots): + imgui.text(s) + + def _render_context_presets(self) -> None: + imgui.text("Presets") + presets = self.controller.project.get('context_presets', {}) + preset_names = [""] + sorted(presets.keys()) + active = getattr(self, "ui_active_context_preset", "") + if active not in preset_names: + active = "" + try: + idx = preset_names.index(active) + except ValueError: + idx = 0 + ch, new_idx = imgui.combo("##ctx_preset", idx, preset_names) + if ch: + self.ui_active_context_preset = preset_names[new_idx] + if preset_names[new_idx]: + self.load_context_preset(preset_names[new_idx]) + imgui.same_line() + changed, new_name = imgui.input_text("##new_preset", getattr(self, "ui_new_context_preset_name", "")) + if changed: + self.ui_new_context_preset_name = new_name + imgui.same_line() + if imgui.button("Save##ctx"): + if getattr(self, "ui_new_context_preset_name", "").strip(): + self.save_context_preset(self.ui_new_context_preset_name.strip()) + self.ui_new_context_preset_name = "" + imgui.same_line() + if imgui.button("Delete##ctx"): + if getattr(self, "ui_active_context_preset", ""): + self.delete_context_preset(self.ui_active_context_preset) + self.ui_active_context_preset = "" + + def _render_context_composition_panel(self) -> None: + if imgui.collapsing_header("Context Composition##panel"): + total_lines, total_ast = self._update_context_file_stats() + self._render_context_batch_actions(total_lines, total_ast) + self._render_context_files_table() imgui.separator() - - #region: Screenshots if imgui.collapsing_header("Screenshots"): - for i, s in enumerate(self.screenshots): - imgui.text(s) + self._render_context_screenshots() imgui.separator() - imgui.text("Presets") - presets = self.controller.project.get('context_presets', {}) - preset_names = [""] + sorted(presets.keys()) - active = getattr(self, "ui_active_context_preset", "") - if active not in preset_names: - active = "" - try: - idx = preset_names.index(active) - except ValueError: - idx = 0 - ch, new_idx = imgui.combo("##ctx_preset", idx, preset_names) - if ch: - self.ui_active_context_preset = preset_names[new_idx] - if preset_names[new_idx]: - self.load_context_preset(preset_names[new_idx]) - imgui.same_line() - changed, new_name = imgui.input_text("##new_preset", getattr(self, "ui_new_context_preset_name", "")) - if changed: - self.ui_new_context_preset_name = new_name - imgui.same_line() - if imgui.button("Save##ctx"): - if getattr(self, "ui_new_context_preset_name", "").strip(): - self.save_context_preset(self.ui_new_context_preset_name.strip()) - self.ui_new_context_preset_name = "" - imgui.same_line() - if imgui.button("Delete##ctx"): - if getattr(self, "ui_active_context_preset", ""): - self.delete_context_preset(self.ui_active_context_preset) - self.ui_active_context_preset = "" - #endregion Screenshots + self._render_context_presets() def _render_snapshot_tab(self) -> None: if imgui.begin_tab_bar("snapshot_tabs"): @@ -5193,166 +5198,6 @@ def hello(): else: self._flush_disc_entries_to_project(); self._switch_discussion(self.active_discussion); self.ai_status = "track discussion disabled" self._render_discussion_metadata() - def _render_discussion_metadata(self) -> None: - disc_data = self.project.get("discussion", {}).get("discussions", {}).get(self.active_discussion, {}) - git_commit, last_updated = disc_data.get("git_commit", ""), 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)) - imgui.same_line() - if imgui.button("Update Commit"): - if self.ui_project_git_dir: - cmt = project_manager.get_git_commit(self.ui_project_git_dir) - if cmt: disc_data["git_commit"], disc_data["last_updated"], self.ai_status = cmt, project_manager.now_ts(), 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"): - nm = self.ui_disc_new_name_input.strip() - if nm: self._create_discussion(nm); self.ui_disc_new_name_input = "" - imgui.same_line() - if imgui.button("Rename"): - nm = self.ui_disc_new_name_input.strip() - if nm: self._rename_discussion(self.active_discussion, nm); self.ui_disc_new_name_input = "" - imgui.same_line() - if imgui.button("Delete"): self._delete_discussion(self.active_discussion) - - def _render_discussion_entry_controls(self) -> None: - if imgui.button("+ Entry"): self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": True, "ts": project_manager.now_ts()}) - imgui.same_line() - if imgui.button("-All"): - for e in self.disc_entries: e["collapsed"] = True - imgui.same_line() - if imgui.button("+All"): - for e in self.disc_entries: e["collapsed"] = False - imgui.same_line() - if imgui.button("Clear All"): self.disc_entries.clear() - imgui.same_line() - if imgui.button("Save"): self._flush_to_project(); self._flush_to_config(); models.save_config(self.config); self.ai_status = "discussion saved" - _, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history) - imgui.text("Keep Pairs:"); imgui.same_line(); imgui.set_next_item_width(80) - ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1) - if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1 - imgui.same_line() - if imgui.button("Truncate"): - 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" - - def _render_discussion_roles(self) -> None: - if imgui.collapsing_header("Roles"): - with imscope.child("roles_scroll", size_y=100, flags=True): - for i, r in enumerate(list(self.disc_roles)): - with imscope.id(f"role_{i}"): - if imgui.button("X"): self.disc_roles.pop(i); break - imgui.same_line(); imgui.text(r) - ch, self.ui_disc_new_role_input = imgui.input_text("##new_role", self.ui_disc_new_role_input); imgui.same_line() - if imgui.button("Add"): - r = self.ui_disc_new_role_input.strip() - if r and r not in self.disc_roles: self.disc_roles.append(r); self.ui_disc_new_role_input = "" - - def _render_discussion_entries(self) -> None: - with imscope.child("disc_scroll"): - display_entries = self.disc_entries - if self.ui_focus_agent: - tier_usage = self.mma_tier_usage.get(self.ui_focus_agent) - if tier_usage: - persona_name = tier_usage.get("persona") - if persona_name: display_entries = [e for e in self.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): - self._render_discussion_entry(display_entries[i], i) - if self._scroll_disc_to_bottom: imgui.set_scroll_here_y(1.0); self._scroll_disc_to_bottom = False - - def _render_discussion_entry(self, 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(); self._render_text_viewer(f"Entry #{index+1}", entry["content"]); imgui.same_line(); imgui.set_next_item_width(120) - if imgui.begin_combo("##role", entry["role"]): - for r in self.disc_roles: - 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(); imgui.text_colored(vec4(120, 120, 100), str(ts_str)); e_dt = project_manager.parse_ts(ts_str) - if e_dt: - e_unix, next_unix = e_dt.timestamp(), float('inf') - if index + 1 < len(self.disc_entries): - n_ts = self.disc_entries[index+1].get("ts", ""); n_dt = project_manager.parse_ts(n_ts) - if n_dt: next_unix = n_dt.timestamp() - injected = [f for f in self.files if hasattr(f, 'injected_at') and f.injected_at and e_unix <= f.injected_at < next_unix] - if injected: - imgui.same_line(); imgui.text_colored(vec4(100, 255, 100), f"[{len(injected)}+]") - if imgui.is_item_hovered(): imgui.set_tooltip("Files injected at this point:\n" + "\n".join([f.path for f in injected])) - if collapsed: - imgui.same_line() - if imgui.button("Ins"): self.disc_entries.insert(index, {"role": "User", "content": "", "collapsed": True, "ts": project_manager.now_ts()}) - imgui.same_line() - if imgui.button("Del"): self.disc_entries.pop(index); return - imgui.same_line() - if imgui.button("Branch"): self._branch_discussion(index) - imgui.same_line(); 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] - if len(entry["thinking_segments"][0]["content"]) > 60: preview += "..." - 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: self._render_thinking_trace(thinking_segments, index, is_standalone=not has_content) - if read_mode: self._render_discussion_entry_read_mode(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)) - imgui.separator() - - def _render_discussion_entry_read_mode(self, entry: dict, index: int) -> None: - content = entry["content"] - if not content.strip(): return - if '## Retrieved Context' in content: - rag_match = re.search(r'## Retrieved Context\n\n([\s\S]*?)(?=\n\n#|\Z)', content) - if rag_match: - rag_section = rag_match.group(1) - if imgui.collapsing_header('Retrieved Context'): - chunks = re.finditer(r'### Chunk (\d+) \(Source: (.*?)\)\n([\s\S]*?)(?=\n### Chunk|\Z)', rag_section) - for chunk_match in chunks: - idx, path, chunk_content = chunk_match.group(1), chunk_match.group(2), chunk_match.group(3) - if imgui.collapsing_header(f'Chunk {idx}: {path}'): - if imgui.button(f'[Source]##rag_{index}_{idx}'): - res = mcp_client.read_file(path) - if res: self.text_viewer_title, self.text_viewer_content, self.text_viewer_type, self.show_text_viewer = path, res, (Path(path).suffix.lstrip('.') if Path(path).suffix else 'text'), True - imgui.text_unformatted(chunk_content) - content = content[:rag_match.start()] + content[rag_match.end():] - pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?") - matches, is_nerv = list(pattern.finditer(content)), theme.is_nerv_active() - if not matches: - 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): - 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: - with theme.ai_text_style(): - markdown_helper.render(before, context_id=f'disc_{index}_b_{m_idx}') - header_text, path, code_block = match.group(0).split("\n")[0].strip(), match.group(2), match.group(4) - if imgui.collapsing_header(header_text): - if imgui.button(f"[Source]##{index}_{match.start()}"): - res = mcp_client.read_file(path) - if res: self.text_viewer_title, self.text_viewer_content, self.text_viewer_type, self.show_text_viewer = path, res, (Path(path).suffix.lstrip('.') if Path(path).suffix else 'text'), True - if code_block: - with theme.ai_text_style(): - markdown_helper.render(code_block, context_id=f'disc_{index}_c_{m_idx}') - last_idx = match.end() - after = content[last_idx:] - if after: - with theme.ai_text_style(): - markdown_helper.render(after, context_id=f'disc_{index}_a') - if self.ui_word_wrap: imgui.pop_text_wrap_pos() - def _load_fonts(self) -> None: # Set hello_imgui assets folder to the actual absolute path assets_dir = Path(__file__).parent.parent / "assets"