feat(context): Interactive Text Slice Highlighting with Fuzzy Anchors
This commit is contained in:
+23
-2
@@ -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```")
|
||||
|
||||
@@ -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
|
||||
+42
-2
@@ -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"):
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user