feat(context): Interactive Text Slice Highlighting with Fuzzy Anchors

This commit is contained in:
2026-05-10 13:57:01 -04:00
parent e9eda04a6c
commit 16b99d16a4
5 changed files with 154 additions and 6 deletions
+23 -2
View File
@@ -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_signatures = entry_raw.get("ast_signatures", False)
ast_definitions = entry_raw.get("ast_definitions", False) ast_definitions = entry_raw.get("ast_definitions", False)
ast_mask = entry_raw.get("ast_mask", {}) ast_mask = entry_raw.get("ast_mask", {})
custom_slices = entry_raw.get("custom_slices", [])
elif hasattr(entry_raw, "path"): elif hasattr(entry_raw, "path"):
entry = entry_raw.path entry = entry_raw.path
tier = getattr(entry_raw, "tier", None) 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_signatures = getattr(entry_raw, "ast_signatures", False)
ast_definitions = getattr(entry_raw, "ast_definitions", False) ast_definitions = getattr(entry_raw, "ast_definitions", False)
ast_mask = getattr(entry_raw, "ast_mask", {}) ast_mask = getattr(entry_raw, "ast_mask", {})
custom_slices = getattr(entry_raw, "custom_slices", [])
else: else:
entry = entry_raw entry = entry_raw
tier = None tier = None
@@ -140,11 +142,12 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[
ast_signatures = False ast_signatures = False
ast_definitions = False ast_definitions = False
ast_mask = {} ast_mask = {}
custom_slices = []
if not entry or not isinstance(entry, str): if not entry or not isinstance(entry, str):
continue continue
paths = resolve_paths(base_dir, entry) paths = resolve_paths(base_dir, entry)
if not paths: 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 continue
for path in paths: for path in paths:
try: try:
@@ -159,7 +162,7 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[
content = f"ERROR: {e}" content = f"ERROR: {e}"
mtime = 0.0 mtime = 0.0
error = True 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 return items
@@ -284,6 +287,24 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P
is_focus = True is_focus = True
break break
original = entry if entry and "*" not in entry else (str(path) if path else (entry or "unknown")) 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: if is_focus or tier == 3 or force_full:
suffix = path.suffix.lstrip(".") if path and path.suffix else "text" suffix = path.suffix.lstrip(".") if path and path.suffix else "text"
sections.append(f"### `{original}`\n\n```{suffix}\n{content}\n```") sections.append(f"### `{original}`\n\n```{suffix}\n{content}\n```")
+82
View File
@@ -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
View File
@@ -242,6 +242,7 @@ class App:
self.ui_inspecting_ast_file = None self.ui_inspecting_ast_file = None
self._cached_ast_nodes = [] self._cached_ast_nodes = []
self._cached_ast_file_path = '' self._cached_ast_file_path = ''
self.ui_editing_slices_file = None
"""UI-level wrapper for approving a pending tool execution ask.""" """UI-level wrapper for approving a pending tool execution ask."""
self._handle_approve_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) 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) expanded, opened = imgui.begin(f"Text Viewer - {self.text_viewer_title}", self.show_text_viewer)
self.show_text_viewer = bool(opened) self.show_text_viewer = bool(opened)
if not opened:
self.ui_editing_slices_file = None
if expanded: 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 # Toolbar
if imgui.button("Copy"): if imgui.button("Copy"):
imgui.set_clipboard_text(self.text_viewer_content) 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) imgui.begin_child("tv_md_scroll", imgui.ImVec2(-1, -1), True)
markdown_helper.render(self.text_viewer_content, context_id='text_viewer') markdown_helper.render(self.text_viewer_content, context_id='text_viewer')
imgui.end_child() 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: if self._text_viewer_editor is None:
self._text_viewer_editor = ced.TextEditor() self._text_viewer_editor = ced.TextEditor()
self._text_viewer_editor.set_read_only_enabled(True) self._text_viewer_editor.set_read_only_enabled(True)
self._text_viewer_editor.set_show_line_numbers_enabled(True) self._text_viewer_editor.set_show_line_numbers_enabled(True)
# Sync text and language # 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(): 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_text(self.text_viewer_content)
self._text_viewer_editor.set_language_definition(lang_id) self._text_viewer_editor.set_language_definition(lang_id)
@@ -2711,6 +2742,15 @@ class App:
self.ui_inspecting_ast_file = f_item self.ui_inspecting_ast_file = f_item
imgui.open_popup('AST Inspector') 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) imgui.table_set_column_index(1)
if hasattr(f_item, "auto_aggregate"): if hasattr(f_item, "auto_aggregate"):
changed_agg, f_item.auto_aggregate = imgui.checkbox(f"Agg##cc{i}", f_item.auto_aggregate) changed_agg, f_item.auto_aggregate = imgui.checkbox(f"Agg##cc{i}", f_item.auto_aggregate)
+3 -1
View File
@@ -498,6 +498,7 @@ class FileItem:
ast_signatures: bool = False ast_signatures: bool = False
ast_definitions: bool = False ast_definitions: bool = False
ast_mask: dict[str, str] = field(default_factory=dict) ast_mask: dict[str, str] = field(default_factory=dict)
custom_slices: list[dict] = field(default_factory=list)
injected_at: Optional[float] = None injected_at: Optional[float] = None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@@ -511,6 +512,7 @@ class FileItem:
"ast_signatures": self.ast_signatures, "ast_signatures": self.ast_signatures,
"ast_definitions": self.ast_definitions, "ast_definitions": self.ast_definitions,
"ast_mask": self.ast_mask, "ast_mask": self.ast_mask,
"custom_slices": self.custom_slices,
"injected_at": self.injected_at, "injected_at": self.injected_at,
} }
@@ -526,9 +528,9 @@ class FileItem:
ast_signatures=data.get("ast_signatures", False), ast_signatures=data.get("ast_signatures", False),
ast_definitions=data.get("ast_definitions", False), ast_definitions=data.get("ast_definitions", False),
ast_mask=data.get("ast_mask", {}), ast_mask=data.get("ast_mask", {}),
custom_slices=data.get("custom_slices", []),
injected_at=data.get("injected_at"), injected_at=data.get("injected_at"),
) )
@dataclass @dataclass
class Preset: class Preset:
name: str name: str
+3
View File
@@ -8,6 +8,7 @@ def test_file_item_fields():
assert item.auto_aggregate is True assert item.auto_aggregate is True
assert item.force_full is False assert item.force_full is False
assert item.ast_mask == {} assert item.ast_mask == {}
assert item.custom_slices == []
assert item.injected_at is None assert item.injected_at is None
def test_file_item_to_dict(): def test_file_item_to_dict():
@@ -20,6 +21,7 @@ def test_file_item_to_dict():
"ast_signatures": False, "ast_signatures": False,
"ast_definitions": False, "ast_definitions": False,
"ast_mask": {}, "ast_mask": {},
"custom_slices": [],
"injected_at": None "injected_at": None
} }
assert item.to_dict() == expected assert item.to_dict() == expected
@@ -47,4 +49,5 @@ def test_file_item_from_dict_defaults():
assert item.auto_aggregate is True assert item.auto_aggregate is True
assert item.force_full is False assert item.force_full is False
assert item.ast_mask == {} assert item.ast_mask == {}
assert item.custom_slices == []
assert item.injected_at is None assert item.injected_at is None