diff --git a/src/aggregate.py b/src/aggregate.py index 5f61645..96cf4fd 100644 --- a/src/aggregate.py +++ b/src/aggregate.py @@ -124,6 +124,7 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ ast_signatures = entry_raw.get("ast_signatures", False) ast_definitions = entry_raw.get("ast_definitions", False) ast_mask = entry_raw.get("ast_mask", {}) + custom_slices = entry_raw.get("custom_slices", []) elif hasattr(entry_raw, "path"): entry = entry_raw.path tier = getattr(entry_raw, "tier", None) @@ -132,6 +133,7 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ ast_signatures = getattr(entry_raw, "ast_signatures", False) ast_definitions = getattr(entry_raw, "ast_definitions", False) ast_mask = getattr(entry_raw, "ast_mask", {}) + custom_slices = getattr(entry_raw, "custom_slices", []) else: entry = entry_raw tier = None @@ -140,11 +142,12 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ ast_signatures = False ast_definitions = False ast_mask = {} + custom_slices = [] if not entry or not isinstance(entry, str): continue paths = resolve_paths(base_dir, entry) if not paths: - items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True, "mtime": 0.0, "tier": tier, "auto_aggregate": auto_aggregate, "force_full": force_full, "ast_signatures": ast_signatures, "ast_definitions": ast_definitions, "ast_mask": ast_mask}) + items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True, "mtime": 0.0, "tier": tier, "auto_aggregate": auto_aggregate, "force_full": force_full, "ast_signatures": ast_signatures, "ast_definitions": ast_definitions, "ast_mask": ast_mask, "custom_slices": custom_slices}) continue for path in paths: try: @@ -159,7 +162,7 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ content = f"ERROR: {e}" 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, "ast_signatures": ast_signatures, "ast_definitions": ast_definitions, "ast_mask": ast_mask}) + items.append({"path": path, "entry": entry, "content": content, "error": error, "mtime": mtime, "tier": tier, "auto_aggregate": auto_aggregate, "force_full": force_full, "ast_signatures": ast_signatures, "ast_definitions": ast_definitions, "ast_mask": ast_mask, "custom_slices": custom_slices}) return items @@ -284,6 +287,24 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P is_focus = True break original = entry if entry and "*" not in entry else (str(path) if path else (entry or "unknown")) + + slices = item.get('custom_slices', []) + if slices and not item.get('error'): + from src.fuzzy_anchor import FuzzyAnchor + resolved_blocks = [] + content = item.get('content', '') + suffix = path.suffix.lstrip(".") if path and path.suffix else "text" + for slc in slices: + range_res = FuzzyAnchor.resolve_slice(content, slc) + if range_res: + s, e = range_res + lines = content.splitlines() + resolved_blocks.append("\n".join(lines[s-1:e])) + if resolved_blocks: + combined = "\n\n... [LINES SKIPPED] ...\n\n".join(resolved_blocks) + sections.append(f"### `{original}` (Slices)\n\n```{suffix}\n{combined}\n```") + continue # Skip full file logic + if is_focus or tier == 3 or force_full: suffix = path.suffix.lstrip(".") if path and path.suffix else "text" sections.append(f"### `{original}`\n\n```{suffix}\n{content}\n```") diff --git a/src/fuzzy_anchor.py b/src/fuzzy_anchor.py new file mode 100644 index 0000000..d5df414 --- /dev/null +++ b/src/fuzzy_anchor.py @@ -0,0 +1,82 @@ +import hashlib +import re +from typing import Optional, Tuple + +class FuzzyAnchor: + @staticmethod + def get_context(lines: list[str], index: int, count: int, direction: int) -> list[str]: + context = [] + curr = index + while len(context) < count and 0 <= curr < len(lines): + line = lines[curr].strip() + if line: + context.append(line) + curr += direction + return context + + @classmethod + def create_slice(cls, text: str, start_line: int, end_line: int) -> dict: + """start_line and end_line are 1-based.""" + lines = text.splitlines() + s_idx = max(0, start_line - 1) + e_idx = min(len(lines), end_line) + slice_lines = lines[s_idx:e_idx] + slice_text = "\n".join(slice_lines) + + return { + "start_line": start_line, + "end_line": end_line, + "start_context": cls.get_context(lines, s_idx, 3, 1), + "end_context": cls.get_context(lines, e_idx - 1, 3, -1)[::-1], # Reverse back to normal order + "content_hash": hashlib.mdsafe(slice_text.encode()).hexdigest() if hasattr(hashlib, 'mdsafe') else hashlib.md5(slice_text.encode()).hexdigest() + } + + @classmethod + def resolve_slice(cls, text: str, slice_data: dict) -> Optional[Tuple[int, int]]: + lines = text.splitlines() + # 1. Try exact match + s_idx = slice_data["start_line"] - 1 + e_idx = slice_data["end_line"] + if 0 <= s_idx < len(lines) and e_idx <= len(lines): + current_text = "\n".join(lines[s_idx:e_idx]) + curr_hash = hashlib.md5(current_text.encode()).hexdigest() + if curr_hash == slice_data["content_hash"]: + return (slice_data["start_line"], slice_data["end_line"]) + + # 2. Fuzzy match + start_ctx = slice_data["start_context"] + end_ctx = slice_data["end_context"] + if not start_ctx or not end_ctx: return None + + # Search for start_ctx + best_s = -1 + for i in range(len(lines)): + match = True + for j, ctx_line in enumerate(start_ctx): + if i+j >= len(lines) or lines[i+j].strip() != ctx_line: + match = False + break + if match: + best_s = i + break + + if best_s == -1: return None + + # Search for end_ctx after start_ctx + best_e = -1 + for i in range(best_s, len(lines)): + match = True + for j, ctx_line in enumerate(end_ctx): + # end_ctx is the LAST 3 lines. So we match backwards from i. + idx = i - (len(end_ctx) - 1) + j + if idx < 0 or idx >= len(lines) or lines[idx].strip() != ctx_line: + match = False + break + if match: + best_e = i + 1 + break + + if best_e != -1: + return (best_s + 1, best_e) + + return None diff --git a/src/gui_2.py b/src/gui_2.py index 33d8dea..9e916aa 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -242,6 +242,7 @@ class App: self.ui_inspecting_ast_file = None self._cached_ast_nodes = [] self._cached_ast_file_path = '' + self.ui_editing_slices_file = None """UI-level wrapper for approving a pending tool execution ask.""" self._handle_approve_ask() @@ -1259,7 +1260,37 @@ class App: imgui.set_next_window_size(imgui.ImVec2(900, 700), imgui.Cond_.first_use_ever) expanded, opened = imgui.begin(f"Text Viewer - {self.text_viewer_title}", self.show_text_viewer) self.show_text_viewer = bool(opened) + if not opened: + self.ui_editing_slices_file = None + if expanded: + if self.ui_editing_slices_file is not None: + imgui.text("Slice Management") + if imgui.button("Add Selection as Slice"): + selected = self._text_viewer_editor.get_selected_text() + if selected: + # Find line range + full = self.text_viewer_content + start_idx = full.find(selected) + if start_idx != -1: + end_idx = start_idx + len(selected) + s_line = full.count('\n', 0, start_idx) + 1 + e_line = full.count('\n', 0, end_idx) + 1 + from src.fuzzy_anchor import FuzzyAnchor + slice_data = FuzzyAnchor.create_slice(full, s_line, e_line) + self.ui_editing_slices_file.custom_slices.append(slice_data) + + # Render existing slices + to_remove = -1 + for idx, slc in enumerate(self.ui_editing_slices_file.custom_slices): + imgui.text(f"Slice {idx+1}: Lines {slc['start_line']}-{slc['end_line']}") + imgui.same_line() + if imgui.button(f"Remove##slc{idx}"): + to_remove = idx + if to_remove != -1: + self.ui_editing_slices_file.custom_slices.pop(to_remove) + imgui.separator() + # Toolbar if imgui.button("Copy"): imgui.set_clipboard_text(self.text_viewer_content) @@ -1274,14 +1305,14 @@ class App: imgui.begin_child("tv_md_scroll", imgui.ImVec2(-1, -1), True) markdown_helper.render(self.text_viewer_content, context_id='text_viewer') imgui.end_child() - elif tv_type in renderer._lang_map: + elif tv_type in renderer._lang_map or self.ui_editing_slices_file is not None: if self._text_viewer_editor is None: self._text_viewer_editor = ced.TextEditor() self._text_viewer_editor.set_read_only_enabled(True) self._text_viewer_editor.set_show_line_numbers_enabled(True) # Sync text and language - lang_id = renderer._lang_map[tv_type] + lang_id = renderer._lang_map.get(tv_type, ced.TextEditor.LanguageDefinitionId.none) if self._text_viewer_editor.get_text().strip() != self.text_viewer_content.strip(): self._text_viewer_editor.set_text(self.text_viewer_content) self._text_viewer_editor.set_language_definition(lang_id) @@ -2710,6 +2741,15 @@ class App: if imgui.button(f"[Inspect]##{i}"): self.ui_inspecting_ast_file = f_item imgui.open_popup('AST Inspector') + + 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}" + self.text_viewer_content = f_item.content or "" + 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"): diff --git a/src/models.py b/src/models.py index 9a7ac0b..66bc4ca 100644 --- a/src/models.py +++ b/src/models.py @@ -498,6 +498,7 @@ class FileItem: ast_signatures: bool = False ast_definitions: bool = False ast_mask: dict[str, str] = field(default_factory=dict) + custom_slices: list[dict] = field(default_factory=list) injected_at: Optional[float] = None def to_dict(self) -> Dict[str, Any]: @@ -511,6 +512,7 @@ class FileItem: "ast_signatures": self.ast_signatures, "ast_definitions": self.ast_definitions, "ast_mask": self.ast_mask, + "custom_slices": self.custom_slices, "injected_at": self.injected_at, } @@ -526,9 +528,9 @@ class FileItem: ast_signatures=data.get("ast_signatures", False), ast_definitions=data.get("ast_definitions", False), ast_mask=data.get("ast_mask", {}), + custom_slices=data.get("custom_slices", []), injected_at=data.get("injected_at"), - ) - + ) @dataclass class Preset: name: str diff --git a/tests/test_file_item_model.py b/tests/test_file_item_model.py index b8f089b..8296aba 100644 --- a/tests/test_file_item_model.py +++ b/tests/test_file_item_model.py @@ -8,6 +8,7 @@ def test_file_item_fields(): assert item.auto_aggregate is True assert item.force_full is False assert item.ast_mask == {} + assert item.custom_slices == [] assert item.injected_at is None def test_file_item_to_dict(): @@ -20,6 +21,7 @@ def test_file_item_to_dict(): "ast_signatures": False, "ast_definitions": False, "ast_mask": {}, + "custom_slices": [], "injected_at": None } assert item.to_dict() == expected @@ -47,4 +49,5 @@ def test_file_item_from_dict_defaults(): assert item.auto_aggregate is True assert item.force_full is False assert item.ast_mask == {} + assert item.custom_slices == [] assert item.injected_at is None