diff --git a/src/aggregate.py b/src/aggregate.py index da04e0d9..c7e49615 100644 --- a/src/aggregate.py +++ b/src/aggregate.py @@ -199,52 +199,83 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ mtime = path.stat().st_mtime error = False if not error and view_mode != "full": - if view_mode == "summary": - content = summarize.summarise_file(path, content) - elif view_mode == "skeleton": - if path.suffix == ".py": - if not parser: parser = ASTParser("python") - content = parser.get_skeleton(content, path=str(path)) - elif path.suffix in ['.c', '.h', '.cpp', '.hpp', '.cxx', '.cc']: - from src import mcp_client - if path.suffix in ['.c', '.h']: content = mcp_client.ts_c_get_skeleton(str(path)) - else: content = mcp_client.ts_cpp_get_skeleton(str(path)) - else: - content = summarize.summarise_file(path, content) - elif view_mode == "outline": - if path.suffix == ".py": - if not parser: parser = ASTParser("python") - content = parser.get_code_outline(content, path=str(path)) - elif path.suffix in ['.c', '.h', '.cpp', '.hpp', '.cxx', '.cc']: - from src import mcp_client - if path.suffix in ['.c', '.h']: content = mcp_client.ts_c_get_code_outline(str(path)) - else: content = mcp_client.ts_cpp_get_code_outline(str(path)) - else: - content = summarize.summarise_file(path, content) - elif view_mode == "none": - content = "(context excluded)" - elif view_mode == "custom": - if custom_slices: - lines = content.splitlines() - slices_text = [] - for s in custom_slices: - start = s.get("start_line", 1) - end = s.get("end_line", len(lines)) - tag = s.get("tag", "unnamed") - comment = s.get("comment", "") - s_idx = max(0, start - 1) - e_idx = min(len(lines), end) - chunk = "\n".join(lines[s_idx:e_idx]) - slices_text.append(f"---\n[Slice: {tag}] ({comment})\nLines {start}-{end}:\n{chunk}") - content = "\n\n".join(slices_text) - else: + try: + if view_mode == "summary": content = summarize.summarise_file(path, content) + elif view_mode == "skeleton": + suffix_lower = path.suffix.lower() + if suffix_lower == ".py": + if not parser: parser = ASTParser("python") + content = parser.get_skeleton(content, path=str(path)) + elif suffix_lower in ['.c', '.h', '.cpp', '.hpp', '.cxx', '.cc']: + from src import mcp_client + if suffix_lower in ['.c', '.h']: content = mcp_client.ts_c_get_skeleton(str(path)) + else: content = mcp_client.ts_cpp_get_skeleton(str(path)) + else: + content = summarize.summarise_file(path, content) + elif view_mode == "outline": + suffix_lower = path.suffix.lower() + if suffix_lower == ".py": + if not parser: parser = ASTParser("python") + content = parser.get_code_outline(content, path=str(path)) + elif suffix_lower in ['.c', '.h', '.cpp', '.hpp', '.cxx', '.cc']: + from src import mcp_client + if suffix_lower in ['.c', '.h']: content = mcp_client.ts_c_get_code_outline(str(path)) + else: content = mcp_client.ts_cpp_get_code_outline(str(path)) + else: + content = summarize.summarise_file(path, content) + elif view_mode == "masked": + suffix_lower = path.suffix.lower() + if ast_mask: + mask_sections = [] + from src import mcp_client + for symbol, mode in ast_mask.items(): + if mode == "hide": continue + res = "" + if suffix_lower == ".py": + res = mcp_client.py_get_definition(str(path), symbol) if mode == "def" else mcp_client.py_get_signature(str(path), symbol) + elif suffix_lower in [".c", ".h", ".cpp", ".hpp", ".cxx", ".cc"]: + is_cpp = any(ext in suffix_lower for ext in [".cpp", ".hpp", ".cxx", ".cc"]) + if mode == "def": + res = mcp_client.ts_cpp_get_definition(str(path), symbol) if is_cpp else mcp_client.ts_c_get_definition(str(path), symbol) + else: + res = mcp_client.ts_cpp_get_signature(str(path), symbol) if is_cpp else mcp_client.ts_c_get_signature(str(path), symbol) + if res: mask_sections.append(res) + if mask_sections: + content = "\n\n".join(mask_sections) + else: + content = "(no masked sections visible)" + else: + content = "(no ast mask defined)" + elif view_mode == "none": + content = "(context excluded)" + elif view_mode == "custom": + if custom_slices: + lines = content.splitlines() + slices_text = [] + for s in custom_slices: + start = s.get("start_line", 1) + end = s.get("end_line", len(lines)) + tag = s.get("tag", "unnamed") + comment = s.get("comment", "") + s_idx = max(0, start - 1) + e_idx = min(len(lines), end) + chunk = "\n".join(lines[s_idx:e_idx]) + slices_text.append(f"---\n[Slice: {tag}] ({comment})\nLines {start}-{end}:\n{chunk}") + content = "\n\n".join(slices_text) + else: + content = summarize.summarise_file(path, content) + except Exception as e: + import traceback + content = f"ERROR in {view_mode} view mode for {path}:\n{traceback.format_exc()}" + error = True except FileNotFoundError: content = f"ERROR: file not found: {path}" mtime = 0.0 error = True except Exception as e: - content = f"ERROR: {e}" + import traceback + content = f"ERROR reading {path}:\n{traceback.format_exc()}" mtime = 0.0 error = True items.append({"path": path, "entry": entry, "content": content, "error": error, "mtime": mtime, "tier": tier, "auto_aggregate": auto_aggregate, "force_full": force_full, "view_mode": view_mode, "ast_signatures": ast_signatures, "ast_definitions": ast_definitions, "ast_mask": ast_mask, "custom_slices": custom_slices}) diff --git a/src/app_controller.py b/src/app_controller.py index f3f0040d..d6d99226 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -2929,29 +2929,20 @@ class AppController: if name not in presets: raise KeyError(f"Context preset '{name}' not found.") preset = presets[name] - # Apply it to the current state - self.ui_file_paths = [f.path for f in preset.files] - self.screenshots = list(preset.screenshots) - self._save_active_project() - # We need to tell gui_2 to populate the full FileItem state from the preset, - # which it does in _handle_refresh_from_project. But we also need to pass the detailed properties. - # We will let the project_manager handle merging in the preset files via configuration next turn. - # Wait, project manager doesn't load preset files into self.files automatically here. - # Let's write the preset files into self.project["files"] directly. - import copy - self.project.setdefault("files", {})["paths"] = [ - { - "path": f.path, - "view_mode": f.view_mode, - "custom_slices": copy.deepcopy(f.custom_slices), - "ast_mask": copy.deepcopy(f.ast_mask), - "ast_signatures": getattr(f, "ast_signatures", False), - "ast_definitions": getattr(f, "ast_definitions", False) - } for f in preset.files - ] - self._save_active_project() - return preset + # Update only temporary context state, not project files + import copy + self.context_files = [] + for f in preset.files: + fi = models.FileItem(path=f.path, view_mode=f.view_mode) + fi.custom_slices = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] + fi.ast_mask = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} + fi.ast_signatures = getattr(f, 'ast_signatures', False) + fi.ast_definitions = getattr(f, 'ast_definitions', False) + self.context_files.append(fi) + + self.screenshots = list(preset.screenshots) + return preset def _cb_load_track(self, track_id: str) -> None: """ [C: src/gui_2.py:App._render_mma_track_browser] @@ -3459,11 +3450,19 @@ class AppController: models.save_config(self.config) track_id = self.active_track.id if self.active_track else None flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id) - flat.setdefault("files", {})["paths"] = self.context_files + + import copy + flat["files"] = copy.copy(flat.get("files", {})) + flat["files"]["paths"] = self.context_files - # Configure MCP so that aggregate.py can fetch skeletons for external files (e.g. gencpp) - file_dicts = [f.to_dict() if hasattr(f, 'to_dict') else {"path": str(f)} for f in self.context_files] - mcp_client.configure(file_dicts, [self.active_project_root] if self.active_project_root else None) + import os + file_dicts = [] + for f in self.context_files: + p = f.path if hasattr(f, 'path') else str(f) + if not os.path.isabs(p): + p = os.path.join(self.ui_files_base_dir, p) + file_dicts.append({"path": p}) + mcp_client.configure(file_dicts, [self.ui_files_base_dir]) persona = self.personas.get(self.ui_active_persona) strategy = persona.aggregation_strategy if persona else "auto" diff --git a/src/gui_2.py b/src/gui_2.py index e328deb4..71732077 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -627,7 +627,16 @@ class App: [C: tests/test_context_presets.py:test_load_context_preset, tests/test_context_presets.py:test_load_nonexistent_preset] """ preset = self.controller.load_context_preset(name) - self.context_files = [models.FileItem(path=f.path, view_mode=f.view_mode) for f in preset.files] + from src import models + import copy + self.context_files = [] + for f in preset.files: + fi = models.FileItem(path=f.path, view_mode=f.view_mode) + fi.custom_slices = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] + fi.ast_mask = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} + fi.ast_signatures = getattr(f, 'ast_signatures', False) + fi.ast_definitions = getattr(f, 'ast_definitions', False) + self.context_files.append(fi) self.screenshots = list(preset.screenshots) self.ui_file_paths = [f.path for f in preset.files] self.ui_screenshot_paths = list(preset.screenshots) @@ -3017,7 +3026,6 @@ def render_ast_inspector_modal(app: App) -> None: if imgui.is_item_hovered(): app._hovered_ast_node = full_path - # Calculate space left and align radio buttons to the right btn_width = 150 # Estimated width of the 3 radio buttons avail_width = imgui.get_content_region_avail().x if avail_width > btn_width: @@ -3255,7 +3263,6 @@ def render_context_files_table(app: App) -> None: imgui.text_colored(imgui.ImVec4(1.0, 0.5, 0.0, 1.0), "[Slices Active]") def render_context_presets(app: App) -> None: - imgui.text("Presets") presets = app.controller.project.get('context_presets', {}) preset_names = [""] + sorted(presets.keys()) active = getattr(app, "ui_active_context_preset", "") @@ -3264,47 +3271,86 @@ def render_context_presets(app: App) -> None: idx = preset_names.index(active) except ValueError: idx = 0 - ch, new_idx = imgui.combo("##ctx_preset", idx, preset_names) - if ch: - app.ui_active_context_preset = preset_names[new_idx] - if preset_names[new_idx]: app.load_context_preset(preset_names[new_idx]) - imgui.same_line() - changed, new_name = imgui.input_text("##new_preset", getattr(app, "ui_new_context_preset_name", "")) - if changed: app.ui_new_context_preset_name = new_name - imgui.same_line() - if imgui.button("Save##ctx") or getattr(app, "_pending_save_ctx_click", False): - app._pending_save_ctx_click = False - name = getattr(app, "ui_new_context_preset_name", "").strip() - if name: - missing = [] - root = app.controller.active_project_root - for f in app.context_files: - path = f.path if hasattr(f, "path") else str(f) - if not os.path.isabs(path): - full_path = os.path.join(root, path) - else: - full_path = path - if not os.path.exists(full_path): - missing.append(path) - - if missing: - app.missing_context_files = missing - app.show_missing_files_modal = True - app.target_context_preset_name = name - else: + + with imscope.table("ctx_presets_layout", 2, imgui.TableFlags_.none): + imgui.table_next_column() + imgui.set_next_item_width(-1) + ch, new_idx = imgui.combo("##ctx_preset", idx, preset_names) + if ch: + app.ui_active_context_preset = preset_names[new_idx] + if preset_names[new_idx]: + app.controller.load_context_preset(preset_names[new_idx]) + app.controller._refresh_from_project() + app.context_files = list(app.controller.files) + + imgui.table_next_column() + if active: + if imgui.button("Update##override", imgui.ImVec2(-1, 0)): preset_files = [] for f in app.context_files: + import copy + from src import models p = f.path if hasattr(f, 'path') else str(f) vm = f.view_mode if hasattr(f, 'view_mode') else 'summary' - preset_files.append(models.ContextFileEntry(path=p, view_mode=vm)) - preset = models.ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots)) + slc = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] + msk = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} + sig = f.ast_signatures if hasattr(f, 'ast_signatures') else False + dfn = f.ast_definitions if hasattr(f, 'ast_definitions') else False + preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn)) + preset = models.ContextPreset(name=active, files=preset_files, screenshots=list(app.screenshots)) app.controller.save_context_preset(preset) - app.ui_new_context_preset_name = "" - imgui.same_line() - if imgui.button("Delete##ctx"): - if getattr(app, "ui_active_context_preset", ""): - app.delete_context_preset(app.ui_active_context_preset) - app.ui_active_context_preset = "" + else: + imgui.text_disabled("No active preset") + + imgui.table_next_row() + imgui.table_next_column() + imgui.set_next_item_width(-1) + changed, new_name = imgui.input_text("##new_preset", getattr(app, "ui_new_context_preset_name", "")) + if changed: app.ui_new_context_preset_name = new_name + + imgui.table_next_column() + if imgui.button("Save As##ctx", imgui.ImVec2(-1, 0)) or getattr(app, "_pending_save_ctx_click", False): + app._pending_save_ctx_click = False + name = getattr(app, "ui_new_context_preset_name", "").strip() + if name: + missing = [] + root = app.controller.active_project_root + for f in app.context_files: + path = f.path if hasattr(f, "path") else str(f) + if not os.path.isabs(path): + full_path = os.path.join(root, path) + else: + full_path = path + if not os.path.exists(full_path): + missing.append(path) + + if missing: + app.missing_context_files = missing + app.show_missing_files_modal = True + app.target_context_preset_name = name + else: + preset_files = [] + for f in app.context_files: + import copy + from src import models + p = f.path if hasattr(f, 'path') else str(f) + vm = f.view_mode if hasattr(f, 'view_mode') else 'summary' + slc = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] + msk = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} + sig = f.ast_signatures if hasattr(f, 'ast_signatures') else False + dfn = f.ast_definitions if hasattr(f, 'ast_definitions') else False + preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn)) + preset = models.ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots)) + app.controller.save_context_preset(preset) + app.ui_new_context_preset_name = "" + + if active: + imgui.table_next_row() + imgui.table_next_column() + imgui.table_next_column() + if imgui.button("Delete Active", imgui.ImVec2(-1, 0)): + app.delete_context_preset(active) + app.ui_active_context_preset = "" def render_snapshot_tab(app: App) -> None: if imgui.begin_tab_bar("snapshot_tabs"): @@ -5380,9 +5426,15 @@ def render_context_modals(app: App) -> None: name = app.target_context_preset_name preset_files = [] for f in app.context_files: + import copy + from src import models p = f.path if hasattr(f, 'path') else str(f) vm = f.view_mode if hasattr(f, 'view_mode') else 'summary' - preset_files.append(models.ContextFileEntry(path=p, view_mode=vm)) + slc = copy.deepcopy(f.custom_slices) if hasattr(f, 'custom_slices') else [] + msk = copy.deepcopy(f.ast_mask) if hasattr(f, 'ast_mask') else {} + sig = f.ast_signatures if hasattr(f, 'ast_signatures') else False + dfn = f.ast_definitions if hasattr(f, 'ast_definitions') else False + preset_files.append(models.ContextFileEntry(path=p, view_mode=vm, custom_slices=slc, ast_mask=msk, ast_signatures=sig, ast_definitions=dfn)) preset = models.ContextPreset(name=name, files=preset_files, screenshots=list(app.screenshots)) app.controller.save_context_preset(preset) app.ui_new_context_preset_name = "" diff --git a/src/summarize.py b/src/summarize.py index 8abd33e3..84c18c38 100644 --- a/src/summarize.py +++ b/src/summarize.py @@ -22,7 +22,6 @@ For each file, extracts structural information: Returns a compact markdown string per file, suitable for use as a low-token context block that replaces full file contents in the initial send. """ - import ast import re from pathlib import Path @@ -42,7 +41,6 @@ def _summarise_python(path: Path, content: str) -> str: except SyntaxError as e: parts.append(f"_Parse error: {e}_") return "\n".join(parts) - # Imports imports = [] for node in ast.walk(tree): if isinstance(node, ast.Import): @@ -54,7 +52,6 @@ def _summarise_python(path: Path, content: str) -> str: if imports: unique_imports = sorted(set(imports)) parts.append(f"imports: {', '.join(unique_imports)}") - # Top-level constants (ALL_CAPS assignments) constants = [] for node in ast.iter_child_nodes(tree): if isinstance(node, ast.Assign): @@ -66,7 +63,6 @@ def _summarise_python(path: Path, content: str) -> str: constants.append(node.target.id) if constants: parts.append(f"constants: {', '.join(constants)}") - # Classes + their methods for node in ast.iter_child_nodes(tree): if isinstance(node, ast.ClassDef): methods = [ @@ -77,7 +73,6 @@ def _summarise_python(path: Path, content: str) -> str: parts.append(f"class {node.name}: {', '.join(methods)}") else: parts.append(f"class {node.name}") - # Top-level functions top_fns = [ node.name for node in ast.iter_child_nodes(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) @@ -90,7 +85,6 @@ def _summarise_toml(path: Path, content: str) -> str: lines = content.splitlines() line_count = len(lines) parts = [f"**TOML** — {line_count} lines"] - # Extract top-level table headers [key] and [[key]] table_pat = re.compile(r"^\s*\[{1,2}([^\[\]]+)\]{1,2}") tables = [] for line in lines: @@ -99,7 +93,6 @@ def _summarise_toml(path: Path, content: str) -> str: tables.append(m.group(1).strip()) if tables: parts.append(f"tables: {', '.join(tables)}") - # Top-level key = value (not inside a [table]) kv_pat = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*=") in_table = False top_keys = [] @@ -140,7 +133,6 @@ def _summarise_generic(path: Path, content: str) -> str: if preview: parts.append("preview:\n```\n" + "\n".join(preview) + "\n```") return "\n".join(parts) - # ------------------------------------------------------------------ dispatch _SUMMARISERS: dict[str, Callable[[Path, str], str]] = { ".py": _summarise_python, @@ -148,6 +140,10 @@ _SUMMARISERS: dict[str, Callable[[Path, str], str]] = { ".md": _summarise_markdown, ".ini": _summarise_generic, ".txt": _summarise_generic, + ".c": _summarise_generic, + ".h": _summarise_generic, + ".cpp": _summarise_generic, + ".hpp": _summarise_generic, ".ps1": _summarise_generic, } @@ -163,19 +159,17 @@ def summarise_file(path: Path, content: str) -> str: cached = _summary_cache.get_summary(str(path), content_hash) if cached: return cached - suffix = path.suffix.lower() if hasattr(path, "suffix") else "" fn = _SUMMARISERS.get(suffix, _summarise_generic) try: heuristic_outline = fn(path, content) - # Smart AI Summarization is_code = suffix in [".py", ".ps1", ".js", ".ts", ".cpp", ".c", ".h", ".cs", ".go", ".rs", ".lua"] try: from src import ai_client smart_summary = ai_client.run_subagent_summarization( file_path=str(path), - content=content[:10000], # Cap content to 10k chars for summarization + content=content[:10000], is_code=is_code, outline=heuristic_outline ) @@ -184,8 +178,7 @@ def summarise_file(path: Path, content: str) -> str: else: summary = heuristic_outline except Exception: - summary = heuristic_outline # Fallback - + summary = heuristic_outline _summary_cache.set_summary(str(path), content_hash, summary) return summary except Exception as e: