From 5112debe144c828767d97ac9e1194e714bbcd075 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Mon, 11 May 2026 11:37:15 -0400 Subject: [PATCH] feat(context): Implement directory grouping and file stats in context composition panel --- src/aggregate.py | 34 ++++++ src/gui_2.py | 139 ++++++++++++++--------- tests/test_context_composition_phase3.py | 30 +++++ 3 files changed, 151 insertions(+), 52 deletions(-) create mode 100644 tests/test_context_composition_phase3.py diff --git a/src/aggregate.py b/src/aggregate.py index 96cf4fd..d96942e 100644 --- a/src/aggregate.py +++ b/src/aggregate.py @@ -15,6 +15,7 @@ to use the MCP tools to fetch only what it needs. import tomllib import re import glob +import os from pathlib import Path, PureWindowsPath from typing import Any, cast from src import summarize @@ -59,6 +60,39 @@ def resolve_paths(base_dir: Path, entry: str) -> list[Path]: filtered.append(p) return sorted(filtered) +def group_files_by_dir(files: list[Any]) -> dict[str, list[Any]]: + """Groups FileItem objects by their relative directory path.""" + grouped = {} + for f in files: + path_str = f.path if hasattr(f, 'path') else str(f) + # Normalize path separators + path_str = path_str.replace('\\', '/') + dir_name = os.path.dirname(path_str) + if not dir_name: + dir_name = "." + if dir_name not in grouped: + grouped[dir_name] = [] + grouped[dir_name].append(f) + return grouped + +def compute_file_stats(abs_path: str) -> dict[str, int]: + """Computes lines and basic AST stats for a file.""" + stats = {"lines": 0, "ast_elements": 0} + try: + with open(abs_path, 'r', encoding='utf-8') as f: + content = f.read() + stats["lines"] = len(content.splitlines()) + if abs_path.endswith('.py'): + import ast + try: + tree = ast.parse(content) + stats["ast_elements"] = sum(1 for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))) + except Exception: + pass + except Exception: + pass + return stats + def build_discussion_section(history: list[Any]) -> str: """ diff --git a/src/gui_2.py b/src/gui_2.py index a04a497..5a257a7 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -2800,6 +2800,21 @@ class App: imgui.text_disabled("Message & Response panels are detached.") def _render_context_composition_panel(self) -> None: + if not hasattr(self, '_file_stats_cache'): + self._file_stats_cache = {} + + total_lines = 0 + total_ast = 0 + 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: + self._file_stats_cache[cache_key] = aggregate.compute_file_stats(f_path) + stats = self._file_stats_cache[cache_key] + total_lines += stats.get("lines", 0) + total_ast += stats.get("ast_elements", 0) + if imgui.collapsing_header("Context Composition"): #region: Batch Action Bar imgui.text("Batch:") @@ -2881,70 +2896,90 @@ class App: new_files.append(f) self.context_files = new_files self.ui_selected_context_files.clear() - #endregion: Batch Action Bar + 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) + if imgui.begin_table("ctx_comp_table", 2, imgui.TableFlags_.resizable | imgui.TableFlags_.borders): imgui.table_setup_column("File", imgui.TableColumnFlags_.width_stretch) imgui.table_setup_column("Flags", imgui.TableColumnFlags_.width_fixed, 200) imgui.table_headers_row() - for i, f_item in enumerate(self.context_files): + + 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) - - # 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: - 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() - imgui.text(f_path) - - 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 - + is_open = imgui.tree_node_ex(f"{dir_name}##dir_{dir_name}", imgui.TreeNodeFlags_.default_open) imgui.table_set_column_index(1) - if hasattr(f_item, "auto_aggregate"): - changed_agg, f_item.auto_aggregate = imgui.checkbox(f"Agg##cc{i}", f_item.auto_aggregate) - imgui.same_line() - changed_full, f_item.force_full = imgui.checkbox(f"Full##cc{i}", f_item.force_full) - if hasattr(f_item, "ast_signatures"): + 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: + 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() - _, f_item.ast_signatures = imgui.checkbox(f"Sig##cc{i}", f_item.ast_signatures) + + 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() - _, f_item.ast_definitions = imgui.checkbox(f"Def##cc{i}", f_item.ast_definitions) + 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 hasattr(f_item, "auto_aggregate"): + changed_agg, f_item.auto_aggregate = imgui.checkbox(f"Agg##cc{i}", f_item.auto_aggregate) + imgui.same_line() + changed_full, f_item.force_full = imgui.checkbox(f"Full##cc{i}", f_item.force_full) + if hasattr(f_item, "ast_signatures"): + imgui.same_line() + _, f_item.ast_signatures = imgui.checkbox(f"Sig##cc{i}", f_item.ast_signatures) + imgui.same_line() + _, f_item.ast_definitions = imgui.checkbox(f"Def##cc{i}", f_item.ast_definitions) + imgui.tree_pop() imgui.end_table() # Context Composition collasping header diff --git a/tests/test_context_composition_phase3.py b/tests/test_context_composition_phase3.py new file mode 100644 index 0000000..1b850b5 --- /dev/null +++ b/tests/test_context_composition_phase3.py @@ -0,0 +1,30 @@ +import pytest +from src.aggregate import group_files_by_dir, compute_file_stats +from src.models import FileItem + +def test_group_files_by_dir(): + files = [ + FileItem(path="src/main.py"), + FileItem(path="src/utils/helpers.py"), + FileItem(path="tests/test_main.py"), + FileItem(path="README.md") + ] + grouped = group_files_by_dir(files) + assert len(grouped) == 4 + assert grouped["src"] == [files[0]] + assert grouped["src/utils"] == [files[1]] + assert grouped["tests"] == [files[2]] + assert grouped["."] == [files[3]] + +def test_compute_file_stats(): + import tempfile + import os + with tempfile.TemporaryDirectory() as temp_dir: + # Create a dummy python file + py_path = os.path.join(temp_dir, "test.py") + with open(py_path, "w") as f: + f.write("def foo():\n pass\n\nclass Bar:\n pass\n") + + stats = compute_file_stats(py_path) + assert stats["lines"] == 5 + assert stats["ast_elements"] == 2 # 1 func, 1 class