refactor(gui): Modularize _render_context_composition_panel into sub-methods

This commit is contained in:
2026-05-12 20:33:38 -04:00
parent 4823b217bc
commit c0d106255b
2 changed files with 248 additions and 373 deletions
@@ -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.
+208 -363
View File
@@ -3104,7 +3104,7 @@ 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'):
@@ -3112,230 +3112,235 @@ class App:
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
total_lines = 0
total_ast = 0
missing_keys = []
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)
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")
# 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
def _render_context_files_table(self) -> None:
imgui.dummy(imgui.ImVec2(0, 4))
grouped_files = aggregate.group_files_by_dir(self.context_files)
threading.Thread(target=_stats_worker, daemon=True).start()
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()
#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
file_indices = {id(f): idx for idx, f in enumerate(self.context_files)}
imgui.dummy(imgui.ImVec2(0, 4))
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)
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:
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
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()
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)})")
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
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)})")
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
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"]
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]
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"[Save]##vpsave{i}"):
imgui.open_popup(f"save_vp_popup{i}")
imgui.text_colored(imgui.ImVec4(1.0, 0.5, 0.0, 1.0), "[Slices Active]")
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()
def _render_context_screenshots(self) -> None:
for i, s in enumerate(self.screenshots):
imgui.text(s)
imgui.same_line()
if imgui.button(f"[Load]##vpload{i}"):
imgui.open_popup(f"load_vp_popup{i}")
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 = ""
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
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"