klaude-code 1.2.20__py3-none-any.whl → 1.2.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/cli/debug.py +8 -10
- klaude_code/cli/main.py +23 -0
- klaude_code/cli/runtime.py +13 -1
- klaude_code/command/__init__.py +0 -3
- klaude_code/command/prompt-deslop.md +1 -1
- klaude_code/command/thinking_cmd.py +10 -0
- klaude_code/const/__init__.py +2 -5
- klaude_code/core/prompt.py +5 -2
- klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
- klaude_code/core/prompts/{prompt-codex-gpt-5-1.md → prompt-codex.md} +9 -42
- klaude_code/core/reminders.py +36 -2
- klaude_code/core/tool/__init__.py +0 -5
- klaude_code/core/tool/file/_utils.py +6 -0
- klaude_code/core/tool/file/apply_patch_tool.py +30 -72
- klaude_code/core/tool/file/diff_builder.py +151 -0
- klaude_code/core/tool/file/edit_tool.py +35 -18
- klaude_code/core/tool/file/read_tool.py +45 -86
- klaude_code/core/tool/file/write_tool.py +40 -30
- klaude_code/core/tool/shell/bash_tool.py +151 -3
- klaude_code/core/tool/web/mermaid_tool.md +26 -0
- klaude_code/protocol/commands.py +0 -1
- klaude_code/protocol/model.py +29 -10
- klaude_code/protocol/tools.py +1 -2
- klaude_code/session/export.py +75 -20
- klaude_code/session/session.py +7 -0
- klaude_code/session/templates/export_session.html +28 -0
- klaude_code/ui/renderers/common.py +26 -11
- klaude_code/ui/renderers/developer.py +0 -5
- klaude_code/ui/renderers/diffs.py +84 -0
- klaude_code/ui/renderers/tools.py +19 -98
- klaude_code/ui/rich/markdown.py +11 -1
- klaude_code/ui/rich/status.py +8 -11
- klaude_code/ui/rich/theme.py +14 -4
- {klaude_code-1.2.20.dist-info → klaude_code-1.2.22.dist-info}/METADATA +2 -1
- {klaude_code-1.2.20.dist-info → klaude_code-1.2.22.dist-info}/RECORD +37 -40
- klaude_code/command/diff_cmd.py +0 -136
- klaude_code/core/tool/file/multi_edit_tool.md +0 -42
- klaude_code/core/tool/file/multi_edit_tool.py +0 -175
- klaude_code/core/tool/memory/memory_tool.md +0 -20
- klaude_code/core/tool/memory/memory_tool.py +0 -456
- {klaude_code-1.2.20.dist-info → klaude_code-1.2.22.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.20.dist-info → klaude_code-1.2.22.dist-info}/entry_points.txt +0 -0
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import contextlib
|
|
5
|
-
import difflib
|
|
6
5
|
import os
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
8
|
from pydantic import BaseModel
|
|
10
9
|
|
|
11
10
|
from klaude_code.core.tool.file import apply_patch as apply_patch_module
|
|
11
|
+
from klaude_code.core.tool.file._utils import hash_text_sha256
|
|
12
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_file_diff
|
|
12
13
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
13
14
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
14
15
|
from klaude_code.core.tool.tool_registry import register
|
|
@@ -19,7 +20,7 @@ class ApplyPatchHandler:
|
|
|
19
20
|
@classmethod
|
|
20
21
|
async def handle_apply_patch(cls, patch_text: str) -> model.ToolResultItem:
|
|
21
22
|
try:
|
|
22
|
-
output,
|
|
23
|
+
output, diff_ui = await asyncio.to_thread(cls._apply_patch_in_thread, patch_text)
|
|
23
24
|
except apply_patch_module.DiffError as error:
|
|
24
25
|
return model.ToolResultItem(status="error", output=str(error))
|
|
25
26
|
except Exception as error: # pragma: no cover # unexpected errors bubbled to tool result
|
|
@@ -27,11 +28,11 @@ class ApplyPatchHandler:
|
|
|
27
28
|
return model.ToolResultItem(
|
|
28
29
|
status="success",
|
|
29
30
|
output=output,
|
|
30
|
-
ui_extra=
|
|
31
|
+
ui_extra=diff_ui,
|
|
31
32
|
)
|
|
32
33
|
|
|
33
34
|
@staticmethod
|
|
34
|
-
def _apply_patch_in_thread(patch_text: str) -> tuple[str,
|
|
35
|
+
def _apply_patch_in_thread(patch_text: str) -> tuple[str, model.DiffUIExtra]:
|
|
35
36
|
ap = apply_patch_module
|
|
36
37
|
normalized_start = patch_text.lstrip()
|
|
37
38
|
if not normalized_start.startswith("*** Begin Patch"):
|
|
@@ -66,7 +67,7 @@ class ApplyPatchHandler:
|
|
|
66
67
|
|
|
67
68
|
patch, _ = ap.text_to_patch(patch_text, orig)
|
|
68
69
|
commit = ap.patch_to_commit(patch, orig)
|
|
69
|
-
|
|
70
|
+
diff_ui = ApplyPatchHandler._commit_to_structured_diff(commit)
|
|
70
71
|
|
|
71
72
|
def write_fn(path: str, content: str) -> None:
|
|
72
73
|
resolved = resolve_path(path)
|
|
@@ -82,7 +83,11 @@ class ApplyPatchHandler:
|
|
|
82
83
|
with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
|
|
83
84
|
existing = file_tracker.get(resolved)
|
|
84
85
|
is_mem = existing.is_memory if existing else False
|
|
85
|
-
file_tracker[resolved] = model.FileStatus(
|
|
86
|
+
file_tracker[resolved] = model.FileStatus(
|
|
87
|
+
mtime=Path(resolved).stat().st_mtime,
|
|
88
|
+
content_sha256=hash_text_sha256(content),
|
|
89
|
+
is_memory=is_mem,
|
|
90
|
+
)
|
|
86
91
|
|
|
87
92
|
def remove_fn(path: str) -> None:
|
|
88
93
|
resolved = resolve_path(path)
|
|
@@ -97,74 +102,27 @@ class ApplyPatchHandler:
|
|
|
97
102
|
file_tracker.pop(resolved, None)
|
|
98
103
|
|
|
99
104
|
ap.apply_commit(commit, write_fn, remove_fn)
|
|
100
|
-
return "Done!",
|
|
105
|
+
return "Done!", diff_ui
|
|
101
106
|
|
|
102
107
|
@staticmethod
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
for path
|
|
106
|
-
|
|
107
|
-
if
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return lines
|
|
122
|
-
if change.type == apply_patch_module.ActionType.DELETE:
|
|
123
|
-
lines.append(f"diff --git a/{path} b/{path}")
|
|
124
|
-
lines.append("deleted file mode 100644")
|
|
125
|
-
old_lines = ApplyPatchHandler._split_lines(change.old_content)
|
|
126
|
-
lines.extend(ApplyPatchHandler._unified_diff(old_lines, [], fromfile=f"a/{path}", tofile="/dev/null"))
|
|
127
|
-
return lines
|
|
128
|
-
if change.type == apply_patch_module.ActionType.UPDATE:
|
|
129
|
-
new_path = change.move_path or path
|
|
130
|
-
lines.append(f"diff --git a/{path} b/{new_path}")
|
|
131
|
-
if change.move_path and change.move_path != path:
|
|
132
|
-
lines.append(f"rename from {path}")
|
|
133
|
-
lines.append(f"rename to {new_path}")
|
|
134
|
-
old_lines = ApplyPatchHandler._split_lines(change.old_content)
|
|
135
|
-
new_lines = ApplyPatchHandler._split_lines(change.new_content)
|
|
136
|
-
lines.extend(
|
|
137
|
-
ApplyPatchHandler._unified_diff(old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{new_path}")
|
|
138
|
-
)
|
|
139
|
-
return lines
|
|
140
|
-
return lines
|
|
141
|
-
|
|
142
|
-
@staticmethod
|
|
143
|
-
def _unified_diff(
|
|
144
|
-
old_lines: list[str],
|
|
145
|
-
new_lines: list[str],
|
|
146
|
-
*,
|
|
147
|
-
fromfile: str,
|
|
148
|
-
tofile: str,
|
|
149
|
-
) -> list[str]:
|
|
150
|
-
diff_lines = list(
|
|
151
|
-
difflib.unified_diff(
|
|
152
|
-
old_lines,
|
|
153
|
-
new_lines,
|
|
154
|
-
fromfile=fromfile,
|
|
155
|
-
tofile=tofile,
|
|
156
|
-
lineterm="",
|
|
157
|
-
)
|
|
158
|
-
)
|
|
159
|
-
if not diff_lines:
|
|
160
|
-
diff_lines = [f"--- {fromfile}", f"+++ {tofile}"]
|
|
161
|
-
return diff_lines
|
|
162
|
-
|
|
163
|
-
@staticmethod
|
|
164
|
-
def _split_lines(text: str | None) -> list[str]:
|
|
165
|
-
if not text:
|
|
166
|
-
return []
|
|
167
|
-
return text.splitlines()
|
|
108
|
+
def _commit_to_structured_diff(commit: apply_patch_module.Commit) -> model.DiffUIExtra:
|
|
109
|
+
files: list[model.DiffFileDiff] = []
|
|
110
|
+
for path in sorted(commit.changes):
|
|
111
|
+
change = commit.changes[path]
|
|
112
|
+
if change.type == apply_patch_module.ActionType.ADD:
|
|
113
|
+
files.append(build_structured_file_diff("", change.new_content or "", file_path=path))
|
|
114
|
+
elif change.type == apply_patch_module.ActionType.DELETE:
|
|
115
|
+
files.append(build_structured_file_diff(change.old_content or "", "", file_path=path))
|
|
116
|
+
elif change.type == apply_patch_module.ActionType.UPDATE:
|
|
117
|
+
display_path = path
|
|
118
|
+
if change.move_path and change.move_path != path:
|
|
119
|
+
display_path = f"{path} → {change.move_path}"
|
|
120
|
+
files.append(
|
|
121
|
+
build_structured_file_diff(
|
|
122
|
+
change.old_content or "", change.new_content or "", file_path=display_path
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return model.DiffUIExtra(files=files)
|
|
168
126
|
|
|
169
127
|
|
|
170
128
|
@register(tools.APPLY_PATCH)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from diff_match_patch import diff_match_patch # type: ignore[import-untyped]
|
|
7
|
+
|
|
8
|
+
from klaude_code.protocol import model
|
|
9
|
+
|
|
10
|
+
_MAX_LINE_LENGTH_FOR_CHAR_DIFF = 2000
|
|
11
|
+
_DEFAULT_CONTEXT_LINES = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_structured_diff(before: str, after: str, *, file_path: str) -> model.DiffUIExtra:
|
|
15
|
+
"""Build a structured diff with char-level spans for a single file."""
|
|
16
|
+
file_diff = _build_file_diff(before, after, file_path=file_path)
|
|
17
|
+
return model.DiffUIExtra(files=[file_diff])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_structured_file_diff(before: str, after: str, *, file_path: str) -> model.DiffFileDiff:
|
|
21
|
+
"""Build a structured diff for a single file."""
|
|
22
|
+
return _build_file_diff(before, after, file_path=file_path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_file_diff(before: str, after: str, *, file_path: str) -> model.DiffFileDiff:
|
|
26
|
+
before_lines = _split_lines(before)
|
|
27
|
+
after_lines = _split_lines(after)
|
|
28
|
+
|
|
29
|
+
matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
|
|
30
|
+
lines: list[model.DiffLine] = []
|
|
31
|
+
stats_add = 0
|
|
32
|
+
stats_remove = 0
|
|
33
|
+
|
|
34
|
+
grouped_opcodes = matcher.get_grouped_opcodes(n=_DEFAULT_CONTEXT_LINES)
|
|
35
|
+
for group_idx, group in enumerate(grouped_opcodes):
|
|
36
|
+
if group_idx > 0:
|
|
37
|
+
lines.append(_gap_line())
|
|
38
|
+
|
|
39
|
+
# Anchor line numbers to the actual start of the displayed hunk in the "after" file.
|
|
40
|
+
new_line_no = group[0][3] + 1
|
|
41
|
+
|
|
42
|
+
for tag, i1, i2, j1, j2 in group:
|
|
43
|
+
if tag == "equal":
|
|
44
|
+
for line in after_lines[j1:j2]:
|
|
45
|
+
lines.append(_ctx_line(line, new_line_no))
|
|
46
|
+
new_line_no += 1
|
|
47
|
+
elif tag == "delete":
|
|
48
|
+
for line in before_lines[i1:i2]:
|
|
49
|
+
lines.append(_remove_line([model.DiffSpan(op="equal", text=line)]))
|
|
50
|
+
stats_remove += 1
|
|
51
|
+
elif tag == "insert":
|
|
52
|
+
for line in after_lines[j1:j2]:
|
|
53
|
+
lines.append(_add_line([model.DiffSpan(op="equal", text=line)], new_line_no))
|
|
54
|
+
stats_add += 1
|
|
55
|
+
new_line_no += 1
|
|
56
|
+
elif tag == "replace":
|
|
57
|
+
old_block = before_lines[i1:i2]
|
|
58
|
+
new_block = after_lines[j1:j2]
|
|
59
|
+
max_len = max(len(old_block), len(new_block))
|
|
60
|
+
for idx in range(max_len):
|
|
61
|
+
old_line = old_block[idx] if idx < len(old_block) else None
|
|
62
|
+
new_line = new_block[idx] if idx < len(new_block) else None
|
|
63
|
+
if old_line is not None and new_line is not None:
|
|
64
|
+
remove_spans, add_spans = _diff_line_spans(old_line, new_line)
|
|
65
|
+
lines.append(_remove_line(remove_spans))
|
|
66
|
+
lines.append(_add_line(add_spans, new_line_no))
|
|
67
|
+
stats_remove += 1
|
|
68
|
+
stats_add += 1
|
|
69
|
+
new_line_no += 1
|
|
70
|
+
elif old_line is not None:
|
|
71
|
+
lines.append(_remove_line([model.DiffSpan(op="equal", text=old_line)]))
|
|
72
|
+
stats_remove += 1
|
|
73
|
+
elif new_line is not None:
|
|
74
|
+
lines.append(_add_line([model.DiffSpan(op="equal", text=new_line)], new_line_no))
|
|
75
|
+
stats_add += 1
|
|
76
|
+
new_line_no += 1
|
|
77
|
+
|
|
78
|
+
return model.DiffFileDiff(
|
|
79
|
+
file_path=file_path,
|
|
80
|
+
lines=lines,
|
|
81
|
+
stats_add=stats_add,
|
|
82
|
+
stats_remove=stats_remove,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _split_lines(text: str) -> list[str]:
|
|
87
|
+
if not text:
|
|
88
|
+
return []
|
|
89
|
+
return text.splitlines()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ctx_line(text: str, new_line_no: int) -> model.DiffLine:
|
|
93
|
+
return model.DiffLine(
|
|
94
|
+
kind="ctx",
|
|
95
|
+
new_line_no=new_line_no,
|
|
96
|
+
spans=[model.DiffSpan(op="equal", text=text)],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _gap_line() -> model.DiffLine:
|
|
101
|
+
return model.DiffLine(
|
|
102
|
+
kind="gap",
|
|
103
|
+
new_line_no=None,
|
|
104
|
+
spans=[model.DiffSpan(op="equal", text="")],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _add_line(spans: list[model.DiffSpan], new_line_no: int) -> model.DiffLine:
|
|
109
|
+
return model.DiffLine(kind="add", new_line_no=new_line_no, spans=_ensure_spans(spans))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _remove_line(spans: list[model.DiffSpan]) -> model.DiffLine:
|
|
113
|
+
return model.DiffLine(kind="remove", new_line_no=None, spans=_ensure_spans(spans))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ensure_spans(spans: list[model.DiffSpan]) -> list[model.DiffSpan]:
|
|
117
|
+
if spans:
|
|
118
|
+
return spans
|
|
119
|
+
return [model.DiffSpan(op="equal", text="")]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _diff_line_spans(old_line: str, new_line: str) -> tuple[list[model.DiffSpan], list[model.DiffSpan]]:
|
|
123
|
+
if not _should_char_diff(old_line, new_line):
|
|
124
|
+
return (
|
|
125
|
+
[model.DiffSpan(op="equal", text=old_line)],
|
|
126
|
+
[model.DiffSpan(op="equal", text=new_line)],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
differ = diff_match_patch()
|
|
130
|
+
diffs = cast(list[tuple[int, str]], differ.diff_main(old_line, new_line)) # type: ignore[no-untyped-call]
|
|
131
|
+
differ.diff_cleanupSemantic(diffs) # type: ignore[no-untyped-call]
|
|
132
|
+
|
|
133
|
+
remove_spans: list[model.DiffSpan] = []
|
|
134
|
+
add_spans: list[model.DiffSpan] = []
|
|
135
|
+
|
|
136
|
+
for op, text in diffs:
|
|
137
|
+
if not text:
|
|
138
|
+
continue
|
|
139
|
+
if op == diff_match_patch.DIFF_EQUAL: # type: ignore[no-untyped-call]
|
|
140
|
+
remove_spans.append(model.DiffSpan(op="equal", text=text))
|
|
141
|
+
add_spans.append(model.DiffSpan(op="equal", text=text))
|
|
142
|
+
elif op == diff_match_patch.DIFF_DELETE: # type: ignore[no-untyped-call]
|
|
143
|
+
remove_spans.append(model.DiffSpan(op="delete", text=text))
|
|
144
|
+
elif op == diff_match_patch.DIFF_INSERT: # type: ignore[no-untyped-call]
|
|
145
|
+
add_spans.append(model.DiffSpan(op="insert", text=text))
|
|
146
|
+
|
|
147
|
+
return _ensure_spans(remove_spans), _ensure_spans(add_spans)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _should_char_diff(old_line: str, new_line: str) -> bool:
|
|
151
|
+
return len(old_line) <= _MAX_LINE_LENGTH_FOR_CHAR_DIFF and len(new_line) <= _MAX_LINE_LENGTH_FOR_CHAR_DIFF
|
|
@@ -8,7 +8,8 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel, Field
|
|
10
10
|
|
|
11
|
-
from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
|
|
11
|
+
from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
|
|
12
|
+
from klaude_code.core.tool.file.diff_builder import build_structured_diff
|
|
12
13
|
from klaude_code.core.tool.tool_abc import ToolABC, load_desc
|
|
13
14
|
from klaude_code.core.tool.tool_context import get_current_file_tracker
|
|
14
15
|
from klaude_code.core.tool.tool_registry import register
|
|
@@ -55,7 +56,6 @@ class EditTool(ToolABC):
|
|
|
55
56
|
},
|
|
56
57
|
)
|
|
57
58
|
|
|
58
|
-
# Validation utility for MultiEdit integration
|
|
59
59
|
@classmethod
|
|
60
60
|
def valid(
|
|
61
61
|
cls, *, content: str, old_string: str, new_string: str, replace_all: bool
|
|
@@ -74,7 +74,6 @@ class EditTool(ToolABC):
|
|
|
74
74
|
)
|
|
75
75
|
return None
|
|
76
76
|
|
|
77
|
-
# Execute utility for MultiEdit integration
|
|
78
77
|
@classmethod
|
|
79
78
|
def execute(cls, *, content: str, old_string: str, new_string: str, replace_all: bool) -> str:
|
|
80
79
|
if old_string == "":
|
|
@@ -112,6 +111,7 @@ class EditTool(ToolABC):
|
|
|
112
111
|
|
|
113
112
|
# FileTracker checks (only for editing existing files)
|
|
114
113
|
file_tracker = get_current_file_tracker()
|
|
114
|
+
tracked_status: model.FileStatus | None = None
|
|
115
115
|
if not file_exists(file_path):
|
|
116
116
|
# We require reading before editing
|
|
117
117
|
return model.ToolResultItem(
|
|
@@ -125,17 +125,6 @@ class EditTool(ToolABC):
|
|
|
125
125
|
status="error",
|
|
126
126
|
output=("File has not been read yet. Read it first before writing to it."),
|
|
127
127
|
)
|
|
128
|
-
try:
|
|
129
|
-
current_mtime = Path(file_path).stat().st_mtime
|
|
130
|
-
except Exception:
|
|
131
|
-
current_mtime = tracked_status.mtime
|
|
132
|
-
if current_mtime != tracked_status.mtime:
|
|
133
|
-
return model.ToolResultItem(
|
|
134
|
-
status="error",
|
|
135
|
-
output=(
|
|
136
|
-
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
137
|
-
),
|
|
138
|
-
)
|
|
139
128
|
|
|
140
129
|
# Edit existing file: validate and apply
|
|
141
130
|
try:
|
|
@@ -146,6 +135,31 @@ class EditTool(ToolABC):
|
|
|
146
135
|
output="File has not been read yet. Read it first before writing to it.",
|
|
147
136
|
)
|
|
148
137
|
|
|
138
|
+
# Re-check external modifications using content hash when available.
|
|
139
|
+
if tracked_status is not None:
|
|
140
|
+
if tracked_status.content_sha256 is not None:
|
|
141
|
+
current_sha256 = hash_text_sha256(before)
|
|
142
|
+
if current_sha256 != tracked_status.content_sha256:
|
|
143
|
+
return model.ToolResultItem(
|
|
144
|
+
status="error",
|
|
145
|
+
output=(
|
|
146
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
# Backward-compat: old sessions only stored mtime.
|
|
151
|
+
try:
|
|
152
|
+
current_mtime = Path(file_path).stat().st_mtime
|
|
153
|
+
except Exception:
|
|
154
|
+
current_mtime = tracked_status.mtime
|
|
155
|
+
if current_mtime != tracked_status.mtime:
|
|
156
|
+
return model.ToolResultItem(
|
|
157
|
+
status="error",
|
|
158
|
+
output=(
|
|
159
|
+
"File has been modified externally. Either by user or a linter. Read it first before writing to it."
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
149
163
|
err = cls.valid(
|
|
150
164
|
content=before,
|
|
151
165
|
old_string=args.old_string,
|
|
@@ -187,15 +201,18 @@ class EditTool(ToolABC):
|
|
|
187
201
|
n=3,
|
|
188
202
|
)
|
|
189
203
|
)
|
|
190
|
-
|
|
191
|
-
ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
|
|
204
|
+
ui_extra = build_structured_diff(before, after, file_path=file_path)
|
|
192
205
|
|
|
193
|
-
# Update tracker with new mtime
|
|
206
|
+
# Update tracker with new mtime and content hash
|
|
194
207
|
if file_tracker is not None:
|
|
195
208
|
with contextlib.suppress(Exception):
|
|
196
209
|
existing = file_tracker.get(file_path)
|
|
197
210
|
is_mem = existing.is_memory if existing else False
|
|
198
|
-
file_tracker[file_path] = model.FileStatus(
|
|
211
|
+
file_tracker[file_path] = model.FileStatus(
|
|
212
|
+
mtime=Path(file_path).stat().st_mtime,
|
|
213
|
+
content_sha256=hash_text_sha256(after),
|
|
214
|
+
is_memory=is_mem,
|
|
215
|
+
)
|
|
199
216
|
|
|
200
217
|
# Build output message
|
|
201
218
|
if args.replace_all:
|