vibefpmgui.ai_edits
Edits proposed by the AI (the Review panel's Edit mode), as search/replace blocks::
<<<<<<< SEARCH
do i = 1, n
=======
do j = 1, n
>>>>>>> REPLACE
A block is found in the file by its text, not by line numbers (which small models get wrong, and which every earlier edit would shift). When the exact text isn't there, it's looked for again ignoring trailing spaces, then indentation (the replacement is then re-indented to match), then with line numbers the model copied from the prompt taken out. An edit that matches nothing, or more than one place, isn't applied: it never lands anywhere but where it says.
Pure text functions (no Qt), used by vibefpmgui.window.ai_chat and shown by
vibefpmgui.edit_cards.
1"""Edits proposed by the AI (the Review panel's *Edit* mode), as 2search/replace blocks:: 3 4 <<<<<<< SEARCH 5 do i = 1, n 6 ======= 7 do j = 1, n 8 >>>>>>> REPLACE 9 10A block is found in the file by its text, not by line numbers (which small 11models get wrong, and which every earlier edit would shift). When the exact 12text isn't there, it's looked for again ignoring trailing spaces, then 13indentation (the replacement is then re-indented to match), then with line 14numbers the model copied from the prompt taken out. An edit that matches 15nothing, or more than one place, isn't applied: it never lands anywhere but 16where it says. 17 18Pure text functions (no Qt), used by `vibefpmgui.window.ai_chat` and shown by 19`vibefpmgui.edit_cards`. 20""" 21 22from __future__ import annotations 23 24import re 25from dataclasses import dataclass 26 27START_RE = re.compile(r"^\s*<{5,9}\s*SEARCH\s*$") 28DIVIDER_RE = re.compile(r"^\s*={5,9}\s*$") 29END_RE = re.compile(r"^\s*>{5,9}\s*REPLACE\s*$") 30FENCE_RE = re.compile(r"^\s*```") 31NUMBER_RE = re.compile(r"^\s*\d+\s?\| ?") # " 12| " (how the chat numbers a file's lines) 32 33PENDING, APPLIED, REJECTED, STALE, FAILED = "pending", "applied", "rejected", "stale", "failed" 34 35 36@dataclass 37class Edit: 38 search: str # as the model wrote it 39 replace: str 40 start: int = -1 # where it applies in the text it was located in (offsets), -1: nowhere 41 end: int = -1 42 new_text: str = "" # what goes there (the replacement, re-indented if needed) 43 line: int = 0 # the first and last lines it replaces (1-based) 44 end_line: int = 0 45 how: str = "" # "", "spacing", "indentation", "line numbers": what had to be ignored 46 status: str = PENDING 47 problem: str = "" # why it can't be applied 48 diff: list[tuple[str, str]] | None = None # the change as shown when it was made or turned down 49 50 @property 51 def located(self) -> bool: 52 return self.start >= 0 53 54 def block(self) -> str: 55 """The block as the model wrote it.""" 56 return f"<<<<<<< SEARCH\n{self.search}\n=======\n{self.replace}\n>>>>>>> REPLACE" 57 58 59def parse_edits(answer: str) -> list[Edit]: 60 """The complete search/replace blocks in an answer (also one still being 61 written: a block counts once its REPLACE line has come).""" 62 edits: list[Edit] = [] 63 state, search, replace = "", [], [] 64 for line in answer.replace("\r\n", "\n").split("\n"): 65 if state == "": 66 if START_RE.match(line): 67 state, search, replace = "search", [], [] 68 elif state == "search": 69 if DIVIDER_RE.match(line): 70 state = "replace" 71 elif START_RE.match(line): # (a block left unfinished: start again) 72 search = [] 73 else: 74 search.append(line) 75 elif END_RE.match(line): 76 edits.append(Edit(_unfence(search), _unfence(replace))) 77 state = "" 78 else: 79 replace.append(line) 80 return edits 81 82 83def _unfence(lines: list[str]) -> str: 84 """A side of a block, without a ``` fence the model put around it.""" 85 if lines and FENCE_RE.match(lines[0]): 86 lines = lines[1:] 87 if lines and FENCE_RE.match(lines[-1]): 88 lines = lines[:-1] 89 return "\n".join(lines) 90 91 92def locate(text: str, edit: Edit, tab_width: int = 4) -> Edit: 93 """Find where ``edit`` applies in ``text``: sets start/end, new_text, 94 line/end_line and how; or problem (and status FAILED) when it can't.""" 95 edit.start = edit.end = -1 96 edit.problem, edit.how = "", "" 97 if edit.status == FAILED: 98 edit.status = PENDING 99 if not edit.search.strip(): # (nothing to find: add at the end) 100 joiner = "" if not text or text.endswith("\n") else "\n" 101 _found(text, edit, len(text), len(text), joiner + edit.replace.strip("\n") + "\n", "") 102 return edit 103 count = text.count(edit.search) 104 if count == 1: 105 start = text.index(edit.search) 106 _found(text, edit, start, start + len(edit.search), edit.replace, "") 107 return edit 108 if count > 1: 109 return _fail(edit, f"matches {count} places: ask for a more specific change") 110 search, replace = _trim_blank_ends(edit.search.split("\n"), edit.replace.split("\n")) 111 lines = text.split("\n") 112 for numbers in (False, True): 113 if numbers: # (the model copied the prompt's line numbers) 114 if not all(NUMBER_RE.match(s) for s in search if s.strip()): 115 break 116 search = [NUMBER_RE.sub("", s, count=1) for s in search] 117 replace = [NUMBER_RE.sub("", r, count=1) for r in replace] 118 for how, same in (("spacing", lambda a, b: a.rstrip() == b.rstrip()), 119 ("indentation", lambda a, b: a.strip() == b.strip())): 120 places = _places(lines, search, same, tab_width) 121 if len(places) > 1: 122 return _fail(edit, f"matches {len(places)} places: ask for a more specific change") 123 if places: 124 first = places[0] 125 new = replace 126 if how == "indentation": 127 new = _reindent(replace, search, lines[first:first + len(search)], tab_width) 128 start = sum(len(line) + 1 for line in lines[:first]) 129 end = start + len("\n".join(lines[first:first + len(search)])) 130 _found(text, edit, start, end, "\n".join(new), "line numbers" if numbers else how) 131 return edit 132 return _fail(edit, "not found: the model's text doesn't match the file") 133 134 135def _places(lines: list[str], search: list[str], same, tab_width: int) -> list[int]: 136 n = len(search) 137 if n == 0: 138 return [] 139 wanted = [s.expandtabs(tab_width) for s in search] 140 return [i for i in range(len(lines) - n + 1) 141 if all(same(lines[i + k].expandtabs(tab_width), wanted[k]) for k in range(n))] 142 143 144def _trim_blank_ends(search: list[str], replace: list[str]) -> tuple[list[str], list[str]]: 145 """Blank lines around SEARCH (models add them) don't have to match; the 146 same number goes from around REPLACE, when it has them too.""" 147 while search and not search[0].strip(): 148 search = search[1:] 149 if replace and not replace[0].strip(): 150 replace = replace[1:] 151 while search and not search[-1].strip(): 152 search = search[:-1] 153 if replace and not replace[-1].strip(): 154 replace = replace[:-1] 155 return search, replace 156 157 158def _indent(line: str, tab_width: int) -> int: 159 expanded = line.expandtabs(tab_width) 160 return len(expanded) - len(expanded.lstrip(" ")) 161 162 163def _reindent(replace: list[str], search: list[str], found: list[str], tab_width: int) -> list[str]: 164 """Shift the replacement by what the model's indentation was off by.""" 165 pair = next(((s, f) for s, f in zip(search, found, strict=False) if s.strip()), None) 166 if pair is None: 167 return replace 168 shift = _indent(pair[1], tab_width) - _indent(pair[0], tab_width) 169 if shift == 0: 170 return replace 171 shifted = [] 172 for line in replace: 173 if not line.strip(): 174 shifted.append(line) 175 elif shift > 0: 176 shifted.append(" " * shift + line) 177 else: 178 line = line.expandtabs(tab_width) 179 shifted.append(line[min(-shift, _indent(line, tab_width)):]) 180 return shifted 181 182 183def _found(text: str, edit: Edit, start: int, end: int, new_text: str, how: str): 184 # Removing whole lines: take the line break too, not to leave a blank line. 185 if not new_text and (start == 0 or text[start - 1] == "\n"): 186 if end < len(text) and text[end] == "\n": 187 end += 1 188 elif start > 0 and end == len(text): 189 start -= 1 190 edit.start, edit.end, edit.new_text, edit.how = start, end, new_text, how 191 edit.line = text.count("\n", 0, start) + 1 192 edit.end_line = edit.line + text.count("\n", start, max(start, end - 1)) 193 194 195def _fail(edit: Edit, problem: str) -> Edit: 196 edit.problem = problem 197 edit.status = FAILED 198 return edit 199 200 201def apply(text: str, edit: Edit) -> str: 202 """``text`` with a located edit made.""" 203 if not edit.located: 204 raise ValueError("the edit wasn't located") 205 return text[:edit.start] + edit.new_text + text[edit.end:] 206 207 208def apply_all(text: str, edits: list[Edit]) -> tuple[str, list[Edit]]: 209 """``text`` with every located edit made (from the end of the text, so 210 the offsets stay right); edits overlapping one already made are left 211 out. Returns the new text and the edits made.""" 212 made: list[Edit] = [] 213 for edit in sorted((e for e in edits if e.located), key=lambda e: e.start, reverse=True): 214 if made and edit.end > made[-1].start: 215 continue # (overlaps the next one down) 216 text = apply(text, edit) 217 made.append(edit) 218 return text, made[::-1] 219 220 221def report(edits: list[Edit]) -> str: 222 """What became of an answer's edits, said in the next question ("edit 1, 223 at line 30, was applied; edit 2 was rejected"), so the model knows. (In 224 the user's words, not in the model's answer: small models copied notes 225 put there, or a placeholder for the edit, instead of writing an edit.)""" 226 words = {APPLIED: "was applied", PENDING: "is not applied yet", REJECTED: "was rejected by the user", 227 FAILED: "wasn't found in the file", STALE: "no longer matches the file"} 228 parts = [f"edit {i}{f', at line {e.line},' if e.line else ''} {words.get(e.status, 'was not used')}" 229 for i, e in enumerate(edits, 1)] 230 return f"(About your previous answer's edits: {'; '.join(parts)}.)" if parts else ""
37@dataclass 38class Edit: 39 search: str # as the model wrote it 40 replace: str 41 start: int = -1 # where it applies in the text it was located in (offsets), -1: nowhere 42 end: int = -1 43 new_text: str = "" # what goes there (the replacement, re-indented if needed) 44 line: int = 0 # the first and last lines it replaces (1-based) 45 end_line: int = 0 46 how: str = "" # "", "spacing", "indentation", "line numbers": what had to be ignored 47 status: str = PENDING 48 problem: str = "" # why it can't be applied 49 diff: list[tuple[str, str]] | None = None # the change as shown when it was made or turned down 50 51 @property 52 def located(self) -> bool: 53 return self.start >= 0 54 55 def block(self) -> str: 56 """The block as the model wrote it.""" 57 return f"<<<<<<< SEARCH\n{self.search}\n=======\n{self.replace}\n>>>>>>> REPLACE"
60def parse_edits(answer: str) -> list[Edit]: 61 """The complete search/replace blocks in an answer (also one still being 62 written: a block counts once its REPLACE line has come).""" 63 edits: list[Edit] = [] 64 state, search, replace = "", [], [] 65 for line in answer.replace("\r\n", "\n").split("\n"): 66 if state == "": 67 if START_RE.match(line): 68 state, search, replace = "search", [], [] 69 elif state == "search": 70 if DIVIDER_RE.match(line): 71 state = "replace" 72 elif START_RE.match(line): # (a block left unfinished: start again) 73 search = [] 74 else: 75 search.append(line) 76 elif END_RE.match(line): 77 edits.append(Edit(_unfence(search), _unfence(replace))) 78 state = "" 79 else: 80 replace.append(line) 81 return edits
The complete search/replace blocks in an answer (also one still being written: a block counts once its REPLACE line has come).
93def locate(text: str, edit: Edit, tab_width: int = 4) -> Edit: 94 """Find where ``edit`` applies in ``text``: sets start/end, new_text, 95 line/end_line and how; or problem (and status FAILED) when it can't.""" 96 edit.start = edit.end = -1 97 edit.problem, edit.how = "", "" 98 if edit.status == FAILED: 99 edit.status = PENDING 100 if not edit.search.strip(): # (nothing to find: add at the end) 101 joiner = "" if not text or text.endswith("\n") else "\n" 102 _found(text, edit, len(text), len(text), joiner + edit.replace.strip("\n") + "\n", "") 103 return edit 104 count = text.count(edit.search) 105 if count == 1: 106 start = text.index(edit.search) 107 _found(text, edit, start, start + len(edit.search), edit.replace, "") 108 return edit 109 if count > 1: 110 return _fail(edit, f"matches {count} places: ask for a more specific change") 111 search, replace = _trim_blank_ends(edit.search.split("\n"), edit.replace.split("\n")) 112 lines = text.split("\n") 113 for numbers in (False, True): 114 if numbers: # (the model copied the prompt's line numbers) 115 if not all(NUMBER_RE.match(s) for s in search if s.strip()): 116 break 117 search = [NUMBER_RE.sub("", s, count=1) for s in search] 118 replace = [NUMBER_RE.sub("", r, count=1) for r in replace] 119 for how, same in (("spacing", lambda a, b: a.rstrip() == b.rstrip()), 120 ("indentation", lambda a, b: a.strip() == b.strip())): 121 places = _places(lines, search, same, tab_width) 122 if len(places) > 1: 123 return _fail(edit, f"matches {len(places)} places: ask for a more specific change") 124 if places: 125 first = places[0] 126 new = replace 127 if how == "indentation": 128 new = _reindent(replace, search, lines[first:first + len(search)], tab_width) 129 start = sum(len(line) + 1 for line in lines[:first]) 130 end = start + len("\n".join(lines[first:first + len(search)])) 131 _found(text, edit, start, end, "\n".join(new), "line numbers" if numbers else how) 132 return edit 133 return _fail(edit, "not found: the model's text doesn't match the file")
Find where edit applies in text: sets start/end, new_text,
line/end_line and how; or problem (and status FAILED) when it can't.
202def apply(text: str, edit: Edit) -> str: 203 """``text`` with a located edit made.""" 204 if not edit.located: 205 raise ValueError("the edit wasn't located") 206 return text[:edit.start] + edit.new_text + text[edit.end:]
text with a located edit made.
209def apply_all(text: str, edits: list[Edit]) -> tuple[str, list[Edit]]: 210 """``text`` with every located edit made (from the end of the text, so 211 the offsets stay right); edits overlapping one already made are left 212 out. Returns the new text and the edits made.""" 213 made: list[Edit] = [] 214 for edit in sorted((e for e in edits if e.located), key=lambda e: e.start, reverse=True): 215 if made and edit.end > made[-1].start: 216 continue # (overlaps the next one down) 217 text = apply(text, edit) 218 made.append(edit) 219 return text, made[::-1]
text with every located edit made (from the end of the text, so
the offsets stay right); edits overlapping one already made are left
out. Returns the new text and the edits made.
222def report(edits: list[Edit]) -> str: 223 """What became of an answer's edits, said in the next question ("edit 1, 224 at line 30, was applied; edit 2 was rejected"), so the model knows. (In 225 the user's words, not in the model's answer: small models copied notes 226 put there, or a placeholder for the edit, instead of writing an edit.)""" 227 words = {APPLIED: "was applied", PENDING: "is not applied yet", REJECTED: "was rejected by the user", 228 FAILED: "wasn't found in the file", STALE: "no longer matches the file"} 229 parts = [f"edit {i}{f', at line {e.line},' if e.line else ''} {words.get(e.status, 'was not used')}" 230 for i, e in enumerate(edits, 1)] 231 return f"(About your previous answer's edits: {'; '.join(parts)}.)" if parts else ""
What became of an answer's edits, said in the next question ("edit 1, at line 30, was applied; edit 2 was rejected"), so the model knows. (In the user's words, not in the model's answer: small models copied notes put there, or a placeholder for the edit, instead of writing an edit.)