fix: make text_editor line numbering 1-based to match grep/sed/editors

linuztx committed Feb 27, 2026 at 10:09 UTC 9419c0be35d6cb436c17bdb046e9f87bdba76d27
2 files changed +19 -14
plugins/text_editor/helpers/file_ops.py
+18 -13
@@ -59,7 +59,7 @@ class ReadResult:
59
60 def read_file(
61 path: str,
62 - line_from: int = 0,
62 + line_from: int = 1,
63 line_to: int | None = None,
64 max_line_tokens: int = 500,
65 default_line_count: int = 100,
@@ -68,8 +68,9 @@ def read_file(
68 """
69 Read a text file and return numbered lines with token budgeting.
70
71 - line_from and line_to are both inclusive. None line_to defaults to
72 - line_from + default_line_count - 1.
71 + Line numbers are 1-based (matching grep, sed, editors).
72 + line_from and line_to are both inclusive.
73 + None line_to defaults to line_from + default_line_count - 1.
74 """
75 path = os.path.expanduser(path)
76
@@ -86,13 +87,15 @@ def read_file(
87 return ReadResult(error=str(exc))
88
89 total_lines = len(all_lines)
89 - line_from = max(line_from, 0)
90 + line_from = max(line_from, 1)
91 if line_to is None:
92 line_to = line_from + default_line_count - 1
92 - line_to = min(line_to, total_lines - 1)
93 + line_to = min(line_to, total_lines)
94
94 - # Slice is exclusive on the right, so +1
95 - selected = all_lines[line_from:line_to + 1]
95 + # Convert 1-based inclusive range to 0-based slice
96 + idx_from = line_from - 1
97 + idx_to = line_to # slice is exclusive, line_to is inclusive 1-based
98 + selected = all_lines[idx_from:idx_to]
99
100 warn_parts: list[str] = []
101 cropped_lines: list[int] = []
@@ -101,7 +104,7 @@ def read_file(
104 trimmed_by_total = False
105
106 for i, raw_line in enumerate(selected):
104 - line_no = line_from + i
107 + line_no = line_from + i # 1-based
108 stripped = raw_line.rstrip("\n").rstrip("\r")
109 line_tok = tokens.count_tokens(stripped)
110
@@ -185,6 +188,7 @@ def validate_edits(edits: list | None) -> tuple[list[dict], str]:
188 """
189 Normalise and validate an edits array.
190
191 + Line numbers are 1-based (matching grep, sed, editors).
192 Semantics (to is inclusive):
193 {from:2, to:2, content:"x\\n"} - replace line 2
194 {from:1, to:3, content:"x\\n"} - replace lines 1-3
@@ -200,9 +204,9 @@ def validate_edits(edits: list | None) -> tuple[list[dict], str]:
204 for e in edits:
205 if not isinstance(e, dict):
206 return [], f"invalid edit entry: {e}"
203 - frm = int(e.get("from", -1))
204 - if frm < 0:
205 - return [], f"edit missing from: {e}"
207 + frm = int(e.get("from", 0))
208 + if frm < 1:
209 + return [], f"edit missing or invalid from (must be >= 1): {e}"
210 # to == -1 or absent means pure insert (no lines removed)
211 to = int(e.get("to", -1))
212 is_insert = to < 0 or to < frm
@@ -237,7 +241,8 @@ def apply_patch(path: str, edits: list[dict]) -> int:
241 """
242 Apply sorted, validated edits by streaming to a temp file.
243
240 - Edits use inclusive 'to'. Inserts have 'insert': True.
244 + Line numbers are 1-based. Edits use inclusive 'to'.
245 + Inserts have 'insert': True.
246 Returns total line count after patching.
247 """
248 dir_name = os.path.dirname(path) or "."
@@ -248,7 +253,7 @@ def apply_patch(path: str, edits: list[dict]) -> int:
253 os.fdopen(fd, "w", encoding="utf-8") as dst,
254 ):
255 edit_idx = 0
251 - line_no = 0
256 + line_no = 1 # 1-based
257 total_written = 0
258
259 for raw_line in src:
plugins/text_editor/tools/text_editor.py
+1 -1
@@ -33,7 +33,7 @@ class TextEditor(Tool):
33 return self._error("read", path, "path is required")
34
35 cfg = get_config(self.agent)
36 - line_from = int(kwargs.get("line_from", 0))
36 + line_from = int(kwargs.get("line_from", 1))
37 raw_to = kwargs.get("line_to")
38 line_to = int(raw_to) if raw_to is not None else None
39