klaude-code 1.2.21__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.
Files changed (37) hide show
  1. klaude_code/cli/debug.py +8 -10
  2. klaude_code/command/__init__.py +0 -3
  3. klaude_code/command/prompt-deslop.md +1 -1
  4. klaude_code/const/__init__.py +2 -5
  5. klaude_code/core/prompt.py +5 -2
  6. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  7. klaude_code/core/prompts/{prompt-codex-gpt-5-1.md → prompt-codex.md} +9 -42
  8. klaude_code/core/reminders.py +36 -2
  9. klaude_code/core/tool/__init__.py +0 -5
  10. klaude_code/core/tool/file/_utils.py +6 -0
  11. klaude_code/core/tool/file/apply_patch_tool.py +30 -72
  12. klaude_code/core/tool/file/diff_builder.py +151 -0
  13. klaude_code/core/tool/file/edit_tool.py +35 -18
  14. klaude_code/core/tool/file/read_tool.py +45 -86
  15. klaude_code/core/tool/file/write_tool.py +40 -30
  16. klaude_code/core/tool/shell/bash_tool.py +147 -0
  17. klaude_code/protocol/commands.py +0 -1
  18. klaude_code/protocol/model.py +29 -10
  19. klaude_code/protocol/tools.py +1 -2
  20. klaude_code/session/export.py +75 -20
  21. klaude_code/session/templates/export_session.html +28 -0
  22. klaude_code/ui/renderers/common.py +26 -11
  23. klaude_code/ui/renderers/developer.py +0 -5
  24. klaude_code/ui/renderers/diffs.py +84 -0
  25. klaude_code/ui/renderers/tools.py +19 -98
  26. klaude_code/ui/rich/markdown.py +11 -1
  27. klaude_code/ui/rich/status.py +8 -11
  28. klaude_code/ui/rich/theme.py +14 -4
  29. {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/METADATA +2 -1
  30. {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/RECORD +32 -35
  31. klaude_code/command/diff_cmd.py +0 -136
  32. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  33. klaude_code/core/tool/file/multi_edit_tool.py +0 -175
  34. klaude_code/core/tool/memory/memory_tool.md +0 -20
  35. klaude_code/core/tool/memory/memory_tool.py +0 -456
  36. {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/WHEEL +0 -0
  37. {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/entry_points.txt +0 -0
@@ -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
- diff_text = "\n".join(diff_lines)
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(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
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:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import contextlib
5
+ import hashlib
5
6
  import os
6
7
  from base64 import b64encode
7
8
  from dataclasses import dataclass
@@ -37,6 +38,7 @@ class ReadOptions:
37
38
  limit: int | None
38
39
  char_limit_per_line: int | None = const.READ_CHAR_LIMIT_PER_LINE
39
40
  global_line_cap: int | None = const.READ_GLOBAL_LINE_CAP
41
+ max_total_chars: int | None = const.READ_MAX_CHARS
40
42
 
41
43
 
42
44
  @dataclass
@@ -45,29 +47,32 @@ class ReadSegmentResult:
45
47
  selected_lines: list[tuple[int, str]]
46
48
  selected_chars_count: int
47
49
  remaining_selected_beyond_cap: int
48
- # For large file diagnostics: list of (start_line, end_line, char_count)
49
- segment_char_stats: list[tuple[int, int, int]]
50
+ remaining_due_to_char_limit: int
51
+ content_sha256: str
50
52
 
51
53
 
52
54
  def _read_segment(options: ReadOptions) -> ReadSegmentResult:
53
55
  total_lines = 0
54
56
  selected_lines_count = 0
55
57
  remaining_selected_beyond_cap = 0
58
+ remaining_due_to_char_limit = 0
56
59
  selected_lines: list[tuple[int, str]] = []
57
60
  selected_chars = 0
58
-
59
- # Track char counts per 100-line segment for diagnostics
60
- segment_size = 100
61
- segment_char_stats: list[tuple[int, int, int]] = []
62
- current_segment_start = options.offset
63
- current_segment_chars = 0
61
+ char_limit_reached = False
62
+ hasher = hashlib.sha256()
64
63
 
65
64
  with open(options.file_path, encoding="utf-8", errors="replace") as f:
66
65
  for line_no, raw_line in enumerate(f, start=1):
67
66
  total_lines = line_no
67
+ hasher.update(raw_line.encode("utf-8"))
68
68
  within = line_no >= options.offset and (options.limit is None or selected_lines_count < options.limit)
69
69
  if not within:
70
70
  continue
71
+
72
+ if char_limit_reached:
73
+ remaining_due_to_char_limit += 1
74
+ continue
75
+
71
76
  selected_lines_count += 1
72
77
  content = raw_line.rstrip("\n")
73
78
  original_len = len(content)
@@ -79,42 +84,39 @@ def _read_segment(options: ReadOptions) -> ReadSegmentResult:
79
84
  )
80
85
  line_chars = len(content) + 1
81
86
  selected_chars += line_chars
82
- current_segment_chars += line_chars
83
87
 
84
- # Check if we've completed a segment
85
- if selected_lines_count % segment_size == 0:
86
- segment_char_stats.append((current_segment_start, line_no, current_segment_chars))
87
- current_segment_start = line_no + 1
88
- current_segment_chars = 0
88
+ if options.max_total_chars is not None and selected_chars > options.max_total_chars:
89
+ char_limit_reached = True
90
+ selected_lines.append((line_no, content))
91
+ continue
89
92
 
90
93
  if options.global_line_cap is None or len(selected_lines) < options.global_line_cap:
91
94
  selected_lines.append((line_no, content))
92
95
  else:
93
96
  remaining_selected_beyond_cap += 1
94
97
 
95
- # Add the last partial segment if any
96
- if current_segment_chars > 0 and selected_lines_count > 0:
97
- last_line = options.offset + selected_lines_count - 1
98
- segment_char_stats.append((current_segment_start, last_line, current_segment_chars))
99
-
100
98
  return ReadSegmentResult(
101
99
  total_lines=total_lines,
102
100
  selected_lines=selected_lines,
103
101
  selected_chars_count=selected_chars,
104
102
  remaining_selected_beyond_cap=remaining_selected_beyond_cap,
105
- segment_char_stats=segment_char_stats,
103
+ remaining_due_to_char_limit=remaining_due_to_char_limit,
104
+ content_sha256=hasher.hexdigest(),
106
105
  )
107
106
 
108
107
 
109
- def _track_file_access(file_path: str, *, is_memory: bool = False) -> None:
108
+ def _track_file_access(file_path: str, *, content_sha256: str | None = None, is_memory: bool = False) -> None:
110
109
  file_tracker = get_current_file_tracker()
111
110
  if file_tracker is None or not file_exists(file_path) or is_directory(file_path):
112
111
  return
113
112
  with contextlib.suppress(Exception):
114
113
  existing = file_tracker.get(file_path)
115
- # Preserve is_memory flag if already set
116
114
  is_mem = is_memory or (existing.is_memory if existing else False)
117
- file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
115
+ file_tracker[file_path] = model.FileStatus(
116
+ mtime=Path(file_path).stat().st_mtime,
117
+ content_sha256=content_sha256,
118
+ is_memory=is_mem,
119
+ )
118
120
 
119
121
 
120
122
  def _is_supported_image_file(file_path: str) -> bool:
@@ -129,12 +131,6 @@ def _image_mime_type(file_path: str) -> str:
129
131
  return mime_type
130
132
 
131
133
 
132
- def _encode_image_to_data_url(file_path: str, mime_type: str) -> str:
133
- with open(file_path, "rb") as image_file:
134
- encoded = b64encode(image_file.read()).decode("ascii")
135
- return f"data:{mime_type};base64,{encoded}"
136
-
137
-
138
134
  @register(tools.READ)
139
135
  class ReadTool(ToolABC):
140
136
  class ReadArguments(BaseModel):
@@ -178,24 +174,18 @@ class ReadTool(ToolABC):
178
174
  return await cls.call_with_args(args)
179
175
 
180
176
  @classmethod
181
- def _effective_limits(cls) -> tuple[int | None, int | None, int | None, int | None]:
182
- """Return effective limits based on current policy: char_per_line, global_line_cap, max_chars, max_kb"""
177
+ def _effective_limits(cls) -> tuple[int | None, int | None, int | None]:
183
178
  return (
184
179
  const.READ_CHAR_LIMIT_PER_LINE,
185
180
  const.READ_GLOBAL_LINE_CAP,
186
181
  const.READ_MAX_CHARS,
187
- const.READ_MAX_KB,
188
182
  )
189
183
 
190
184
  @classmethod
191
185
  async def call_with_args(cls, args: ReadTool.ReadArguments) -> model.ToolResultItem:
192
- # Accept relative path by resolving to absolute (schema encourages absolute)
193
186
  file_path = os.path.abspath(args.file_path)
187
+ char_per_line, line_cap, max_chars = cls._effective_limits()
194
188
 
195
- # Get effective limits based on policy
196
- char_per_line, line_cap, max_chars, max_kb = cls._effective_limits()
197
-
198
- # Common file errors
199
189
  if is_directory(file_path):
200
190
  return model.ToolResultItem(
201
191
  status="error",
@@ -228,11 +218,9 @@ class ReadTool(ToolABC):
228
218
  ),
229
219
  )
230
220
 
231
- # If file is too large and no pagination provided (only check if limits are enabled)
232
221
  try:
233
222
  size_bytes = Path(file_path).stat().st_size
234
223
  except OSError:
235
- # Best-effort size detection; on stat errors fall back to treating size as unknown.
236
224
  size_bytes = 0
237
225
 
238
226
  is_image_file = _is_supported_image_file(file_path)
@@ -247,42 +235,26 @@ class ReadTool(ToolABC):
247
235
  )
248
236
  try:
249
237
  mime_type = _image_mime_type(file_path)
250
- data_url = _encode_image_to_data_url(file_path, mime_type)
238
+ with open(file_path, "rb") as image_file:
239
+ image_bytes = image_file.read()
240
+ data_url = f"data:{mime_type};base64,{b64encode(image_bytes).decode('ascii')}"
251
241
  except Exception as exc:
252
242
  return model.ToolResultItem(
253
243
  status="error",
254
244
  output=f"<tool_use_error>Failed to read image file: {exc}</tool_use_error>",
255
245
  )
256
246
 
257
- _track_file_access(file_path)
247
+ _track_file_access(file_path, content_sha256=hashlib.sha256(image_bytes).hexdigest())
258
248
  size_kb = size_bytes / 1024.0 if size_bytes else 0.0
259
249
  output_text = f"[image] {Path(file_path).name} ({size_kb:.1f}KB)"
260
250
  image_part = model.ImageURLPart(image_url=model.ImageURLPart.ImageURL(url=data_url, id=None))
261
251
  return model.ToolResultItem(status="success", output=output_text, images=[image_part])
262
252
 
263
- if (
264
- not is_image_file
265
- and max_kb is not None
266
- and args.offset is None
267
- and args.limit is None
268
- and size_bytes > max_kb * 1024
269
- ):
270
- size_kb = size_bytes / 1024.0
271
- return model.ToolResultItem(
272
- status="error",
273
- output=(
274
- f"File content ({size_kb:.1f}KB) exceeds maximum allowed size ({max_kb}KB). Please use offset and limit parameters to read specific portions of the file, or use the `rg` command to search for specific content."
275
- ),
276
- )
277
-
278
253
  offset = 1 if args.offset is None or args.offset < 1 else int(args.offset)
279
254
  limit = None if args.limit is None else int(args.limit)
280
255
  if limit is not None and limit < 0:
281
256
  limit = 0
282
257
 
283
- # Stream file line-by-line and build response
284
- read_result: ReadSegmentResult | None = None
285
-
286
258
  try:
287
259
  read_result = await asyncio.to_thread(
288
260
  _read_segment,
@@ -292,6 +264,7 @@ class ReadTool(ToolABC):
292
264
  limit=limit,
293
265
  char_limit_per_line=char_per_line,
294
266
  global_line_cap=line_cap,
267
+ max_total_chars=max_chars,
295
268
  ),
296
269
  )
297
270
 
@@ -306,40 +279,26 @@ class ReadTool(ToolABC):
306
279
  output="<tool_use_error>Illegal operation on a directory. read</tool_use_error>",
307
280
  )
308
281
 
309
- # If offset beyond total lines, emit system reminder warning
310
282
  if offset > max(read_result.total_lines, 0):
311
283
  warn = f"<system-reminder>Warning: the file exists but is shorter than the provided offset ({offset}). The file has {read_result.total_lines} lines.</system-reminder>"
312
- # Update FileTracker (we still consider it as a read attempt)
313
- _track_file_access(file_path)
284
+ _track_file_access(file_path, content_sha256=read_result.content_sha256)
314
285
  return model.ToolResultItem(status="success", output=warn)
315
286
 
316
- # After limit/offset, if total selected chars exceed limit, error (only check if limits are enabled)
317
- if max_chars is not None and read_result.selected_chars_count > max_chars:
318
- # Build segment statistics for better guidance
319
- stats_lines: list[str] = []
320
- for start, end, chars in read_result.segment_char_stats:
321
- stats_lines.append(f" Lines {start}-{end}: {chars} chars")
322
- segment_stats_str = "\n".join(stats_lines) if stats_lines else " (no segment data)"
287
+ lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
323
288
 
324
- return model.ToolResultItem(
325
- status="error",
326
- output=(
327
- f"Selected file content {read_result.selected_chars_count} chars exceeds maximum allowed chars ({max_chars}).\n"
328
- f"File has {read_result.total_lines} total lines.\n\n"
329
- f"Character distribution by segment:\n{segment_stats_str}\n\n"
330
- f"Use offset and limit parameters to read specific portions. "
331
- f"For example: offset=1, limit=100 to read the first 100 lines. "
332
- f"Or use `rg` command to search for specific content."
333
- ),
289
+ # Show truncation info with reason
290
+ if read_result.remaining_due_to_char_limit > 0:
291
+ lines_out.append(
292
+ f"... ({read_result.remaining_due_to_char_limit} more lines truncated due to {max_chars} char limit, "
293
+ f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
294
+ )
295
+ elif read_result.remaining_selected_beyond_cap > 0:
296
+ lines_out.append(
297
+ f"... ({read_result.remaining_selected_beyond_cap} more lines truncated due to {line_cap} line limit, "
298
+ f"file has {read_result.total_lines} lines total, use offset/limit to read other parts)"
334
299
  )
335
300
 
336
- # Build display with numbering and reminders
337
- lines_out: list[str] = [_format_numbered_line(no, content) for no, content in read_result.selected_lines]
338
- if read_result.remaining_selected_beyond_cap > 0:
339
- lines_out.append(f"... (more {read_result.remaining_selected_beyond_cap} lines are truncated)")
340
301
  read_result_str = "\n".join(lines_out)
341
-
342
- # Update FileTracker with last modified time
343
- _track_file_access(file_path)
302
+ _track_file_access(file_path, content_sha256=read_result.content_sha256)
344
303
 
345
304
  return model.ToolResultItem(status="success", output=read_result_str)
@@ -2,13 +2,13 @@ from __future__ import annotations
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
- from klaude_code.core.tool.file._utils import file_exists, is_directory, read_text, write_text
10
+ from klaude_code.core.tool.file._utils import file_exists, hash_text_sha256, is_directory, read_text, write_text
11
+ from klaude_code.core.tool.file.diff_builder import build_structured_diff
12
12
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
13
13
  from klaude_code.core.tool.tool_context import get_current_file_tracker
14
14
  from klaude_code.core.tool.tool_registry import register
@@ -62,36 +62,52 @@ class WriteTool(ToolABC):
62
62
 
63
63
  file_tracker = get_current_file_tracker()
64
64
  exists = file_exists(file_path)
65
+ tracked_status: model.FileStatus | None = None
65
66
 
66
67
  if exists:
67
- tracked_status: model.FileStatus | None = None
68
- if file_tracker is not None:
69
- tracked_status = file_tracker.get(file_path)
68
+ tracked_status = file_tracker.get(file_path) if file_tracker is not None else None
70
69
  if tracked_status is None:
71
70
  return model.ToolResultItem(
72
71
  status="error",
73
72
  output=("File has not been read yet. Read it first before writing to it."),
74
73
  )
75
- try:
76
- current_mtime = Path(file_path).stat().st_mtime
77
- except Exception:
78
- current_mtime = tracked_status.mtime
79
- if current_mtime != tracked_status.mtime:
80
- return model.ToolResultItem(
81
- status="error",
82
- output=(
83
- "File has been modified externally. Either by user or a linter. "
84
- "Read it first before writing to it."
85
- ),
86
- )
87
74
 
88
- # Capture previous content (if any) for diff generation
75
+ # Capture previous content (if any) for diff generation and external-change detection.
89
76
  before = ""
77
+ before_read_ok = False
90
78
  if exists:
91
79
  try:
92
80
  before = await asyncio.to_thread(read_text, file_path)
81
+ before_read_ok = True
93
82
  except Exception:
94
83
  before = ""
84
+ before_read_ok = False
85
+
86
+ # Re-check external modifications using content hash when available.
87
+ if before_read_ok and tracked_status is not None and tracked_status.content_sha256 is not None:
88
+ current_sha256 = hash_text_sha256(before)
89
+ if current_sha256 != tracked_status.content_sha256:
90
+ return model.ToolResultItem(
91
+ status="error",
92
+ output=(
93
+ "File has been modified externally. Either by user or a linter. "
94
+ "Read it first before writing to it."
95
+ ),
96
+ )
97
+ elif tracked_status is not None:
98
+ # Backward-compat: old sessions only stored mtime, or we couldn't hash.
99
+ try:
100
+ current_mtime = Path(file_path).stat().st_mtime
101
+ except Exception:
102
+ current_mtime = tracked_status.mtime
103
+ if current_mtime != tracked_status.mtime:
104
+ return model.ToolResultItem(
105
+ status="error",
106
+ output=(
107
+ "File has been modified externally. Either by user or a linter. "
108
+ "Read it first before writing to it."
109
+ ),
110
+ )
95
111
 
96
112
  try:
97
113
  await asyncio.to_thread(write_text, file_path, args.content)
@@ -102,21 +118,15 @@ class WriteTool(ToolABC):
102
118
  with contextlib.suppress(Exception):
103
119
  existing = file_tracker.get(file_path)
104
120
  is_mem = existing.is_memory if existing else False
105
- file_tracker[file_path] = model.FileStatus(mtime=Path(file_path).stat().st_mtime, is_memory=is_mem)
121
+ file_tracker[file_path] = model.FileStatus(
122
+ mtime=Path(file_path).stat().st_mtime,
123
+ content_sha256=hash_text_sha256(args.content),
124
+ is_memory=is_mem,
125
+ )
106
126
 
107
127
  # Build diff between previous and new content
108
128
  after = args.content
109
- diff_lines = list(
110
- difflib.unified_diff(
111
- before.splitlines(),
112
- after.splitlines(),
113
- fromfile=file_path,
114
- tofile=file_path,
115
- n=3,
116
- )
117
- )
118
- diff_text = "\n".join(diff_lines)
119
- ui_extra = model.DiffTextUIExtra(diff_text=diff_text)
129
+ ui_extra = build_structured_diff(before, after, file_path=file_path)
120
130
 
121
131
  message = f"File {'overwritten' if exists else 'created'} successfully at: {file_path}"
122
132
  return model.ToolResultItem(status="success", output=message, ui_extra=ui_extra)