klaude-code 1.2.6__py3-none-any.whl → 1.8.0__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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -1,13 +1,15 @@
1
1
  """ApplyPatch tool providing direct patch application capability."""
2
2
 
3
3
  import asyncio
4
- import difflib
4
+ import contextlib
5
5
  import os
6
6
  from pathlib import Path
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
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
11
13
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
12
14
  from klaude_code.core.tool.tool_context import get_current_file_tracker
13
15
  from klaude_code.core.tool.tool_registry import register
@@ -18,7 +20,7 @@ class ApplyPatchHandler:
18
20
  @classmethod
19
21
  async def handle_apply_patch(cls, patch_text: str) -> model.ToolResultItem:
20
22
  try:
21
- output, diff_text = await asyncio.to_thread(cls._apply_patch_in_thread, patch_text)
23
+ output, ui_extra = await asyncio.to_thread(cls._apply_patch_in_thread, patch_text)
22
24
  except apply_patch_module.DiffError as error:
23
25
  return model.ToolResultItem(status="error", output=str(error))
24
26
  except Exception as error: # pragma: no cover # unexpected errors bubbled to tool result
@@ -26,11 +28,11 @@ class ApplyPatchHandler:
26
28
  return model.ToolResultItem(
27
29
  status="success",
28
30
  output=output,
29
- ui_extra=model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text),
31
+ ui_extra=ui_extra,
30
32
  )
31
33
 
32
34
  @staticmethod
33
- def _apply_patch_in_thread(patch_text: str) -> tuple[str, str]:
35
+ def _apply_patch_in_thread(patch_text: str) -> tuple[str, model.ToolResultUIExtra]:
34
36
  ap = apply_patch_module
35
37
  normalized_start = patch_text.lstrip()
36
38
  if not normalized_start.startswith("*** Begin Patch"):
@@ -58,14 +60,24 @@ class ApplyPatchHandler:
58
60
  if os.path.isdir(resolved):
59
61
  raise ap.DiffError(f"Cannot apply patch to directory: {path}")
60
62
  try:
61
- with open(resolved, "r", encoding="utf-8") as handle:
63
+ with open(resolved, encoding="utf-8") as handle:
62
64
  orig[path] = handle.read()
63
65
  except OSError as error:
64
66
  raise ap.DiffError(f"Failed to read {path}: {error}") from error
65
67
 
66
68
  patch, _ = ap.text_to_patch(patch_text, orig)
67
69
  commit = ap.patch_to_commit(patch, orig)
68
- diff_text = ApplyPatchHandler._commit_to_diff(commit)
70
+ diff_ui = ApplyPatchHandler._commit_to_structured_diff(commit)
71
+
72
+ md_items: list[model.MarkdownDocUIExtra] = []
73
+ for change_path, change in commit.changes.items():
74
+ if change.type == apply_patch_module.ActionType.ADD and change_path.endswith(".md"):
75
+ md_items.append(
76
+ model.MarkdownDocUIExtra(
77
+ file_path=resolve_path(change_path),
78
+ content=change.new_content or "",
79
+ )
80
+ )
69
81
 
70
82
  def write_fn(path: str, content: str) -> None:
71
83
  resolved = resolve_path(path)
@@ -78,10 +90,14 @@ class ApplyPatchHandler:
78
90
  handle.write(content)
79
91
 
80
92
  if file_tracker is not None:
81
- try:
82
- file_tracker[resolved] = Path(resolved).stat().st_mtime
83
- except Exception: # pragma: no cover - file tracker best-effort
84
- pass
93
+ with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
94
+ existing = file_tracker.get(resolved)
95
+ is_mem = existing.is_memory if existing else False
96
+ file_tracker[resolved] = model.FileStatus(
97
+ mtime=Path(resolved).stat().st_mtime,
98
+ content_sha256=hash_text_sha256(content),
99
+ is_memory=is_mem,
100
+ )
85
101
 
86
102
  def remove_fn(path: str) -> None:
87
103
  resolved = resolve_path(path)
@@ -92,80 +108,44 @@ class ApplyPatchHandler:
92
108
  os.remove(resolved)
93
109
 
94
110
  if file_tracker is not None:
95
- try:
111
+ with contextlib.suppress(Exception): # pragma: no cover - file tracker best-effort
96
112
  file_tracker.pop(resolved, None)
97
- except Exception: # pragma: no cover - file tracker best-effort
98
- pass
99
113
 
100
114
  ap.apply_commit(commit, write_fn, remove_fn)
101
- return "Done!", diff_text
102
115
 
103
- @staticmethod
104
- def _commit_to_diff(commit: apply_patch_module.Commit) -> str:
105
- diff_chunks: list[str] = []
106
- for path, change in commit.changes.items():
107
- chunk = ApplyPatchHandler._render_change_diff(path, change)
108
- if chunk:
109
- if diff_chunks:
110
- diff_chunks.append("")
111
- diff_chunks.extend(chunk)
112
- return "\n".join(diff_chunks)
116
+ # apply_patch can include multiple operations. If we added markdown files,
117
+ # return a MultiUIExtra so UI can render markdown previews (without showing a diff for those markdown adds).
118
+ if md_items:
119
+ items: list[model.MultiUIExtraItem] = []
120
+ items.extend(md_items)
121
+ if diff_ui.files:
122
+ items.append(diff_ui)
123
+ return "Done!", model.MultiUIExtra(items=items)
113
124
 
114
- @staticmethod
115
- def _render_change_diff(path: str, change: apply_patch_module.FileChange) -> list[str]:
116
- lines: list[str] = []
117
- if change.type == apply_patch_module.ActionType.ADD:
118
- lines.append(f"diff --git a/{path} b/{path}")
119
- lines.append("new file mode 100644")
120
- new_lines = ApplyPatchHandler._split_lines(change.new_content)
121
- lines.extend(ApplyPatchHandler._unified_diff([], new_lines, fromfile="/dev/null", tofile=f"b/{path}"))
122
- return lines
123
- if change.type == apply_patch_module.ActionType.DELETE:
124
- lines.append(f"diff --git a/{path} b/{path}")
125
- lines.append("deleted file mode 100644")
126
- old_lines = ApplyPatchHandler._split_lines(change.old_content)
127
- lines.extend(ApplyPatchHandler._unified_diff(old_lines, [], fromfile=f"a/{path}", tofile="/dev/null"))
128
- return lines
129
- if change.type == apply_patch_module.ActionType.UPDATE:
130
- new_path = change.move_path or path
131
- lines.append(f"diff --git a/{path} b/{new_path}")
132
- if change.move_path and change.move_path != path:
133
- lines.append(f"rename from {path}")
134
- lines.append(f"rename to {new_path}")
135
- old_lines = ApplyPatchHandler._split_lines(change.old_content)
136
- new_lines = ApplyPatchHandler._split_lines(change.new_content)
137
- lines.extend(
138
- ApplyPatchHandler._unified_diff(old_lines, new_lines, fromfile=f"a/{path}", tofile=f"b/{new_path}")
139
- )
140
- return lines
141
- return lines
142
-
143
- @staticmethod
144
- def _unified_diff(
145
- old_lines: list[str],
146
- new_lines: list[str],
147
- *,
148
- fromfile: str,
149
- tofile: str,
150
- ) -> list[str]:
151
- diff_lines = list(
152
- difflib.unified_diff(
153
- old_lines,
154
- new_lines,
155
- fromfile=fromfile,
156
- tofile=tofile,
157
- lineterm="",
158
- )
159
- )
160
- if not diff_lines:
161
- diff_lines = [f"--- {fromfile}", f"+++ {tofile}"]
162
- return diff_lines
125
+ return "Done!", diff_ui
163
126
 
164
127
  @staticmethod
165
- def _split_lines(text: str | None) -> list[str]:
166
- if not text:
167
- return []
168
- return text.splitlines()
128
+ def _commit_to_structured_diff(commit: apply_patch_module.Commit) -> model.DiffUIExtra:
129
+ files: list[model.DiffFileDiff] = []
130
+ for path in sorted(commit.changes):
131
+ change = commit.changes[path]
132
+ if change.type == apply_patch_module.ActionType.ADD:
133
+ # For markdown files created via Add File, we render content via MarkdownDocUIExtra instead of a diff.
134
+ if path.endswith(".md"):
135
+ continue
136
+ files.append(build_structured_file_diff("", change.new_content or "", file_path=path))
137
+ elif change.type == apply_patch_module.ActionType.DELETE:
138
+ files.append(build_structured_file_diff(change.old_content or "", "", file_path=path))
139
+ elif change.type == apply_patch_module.ActionType.UPDATE:
140
+ display_path = path
141
+ if change.move_path and change.move_path != path:
142
+ display_path = f"{path} → {change.move_path}"
143
+ files.append(
144
+ build_structured_file_diff(
145
+ change.old_content or "", change.new_content or "", file_path=display_path
146
+ )
147
+ )
148
+ return model.DiffUIExtra(files=files)
169
149
 
170
150
 
171
151
  @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
@@ -1,44 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import difflib
5
6
  import os
6
7
  from pathlib import Path
7
8
 
8
9
  from pydantic import BaseModel, Field
9
10
 
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
10
13
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
11
14
  from klaude_code.core.tool.tool_context import get_current_file_tracker
12
15
  from klaude_code.core.tool.tool_registry import register
13
16
  from klaude_code.protocol import llm_param, model, tools
14
17
 
15
18
 
16
- def _is_directory(path: str) -> bool:
17
- try:
18
- return Path(path).is_dir()
19
- except Exception:
20
- return False
21
-
22
-
23
- def _file_exists(path: str) -> bool:
24
- try:
25
- return Path(path).exists()
26
- except Exception:
27
- return False
28
-
29
-
30
- def _read_text(path: str) -> str:
31
- with open(path, "r", encoding="utf-8", errors="replace") as f:
32
- return f.read()
33
-
34
-
35
- def _write_text(path: str, content: str) -> None:
36
- parent = Path(path).parent
37
- parent.mkdir(parents=True, exist_ok=True)
38
- with open(path, "w", encoding="utf-8") as f:
39
- f.write(content)
40
-
41
-
42
19
  @register(tools.EDIT)
43
20
  class EditTool(ToolABC):
44
21
  class EditArguments(BaseModel):
@@ -79,7 +56,6 @@ class EditTool(ToolABC):
79
56
  },
80
57
  )
81
58
 
82
- # Validation utility for MultiEdit integration
83
59
  @classmethod
84
60
  def valid(
85
61
  cls, *, content: str, old_string: str, new_string: str, replace_all: bool
@@ -98,7 +74,6 @@ class EditTool(ToolABC):
98
74
  )
99
75
  return None
100
76
 
101
- # Execute utility for MultiEdit integration
102
77
  @classmethod
103
78
  def execute(cls, *, content: str, old_string: str, new_string: str, replace_all: bool) -> str:
104
79
  if old_string == "":
@@ -113,13 +88,13 @@ class EditTool(ToolABC):
113
88
  async def call(cls, arguments: str) -> model.ToolResultItem:
114
89
  try:
115
90
  args = EditTool.EditArguments.model_validate_json(arguments)
116
- except Exception as e: # pragma: no cover - defensive
91
+ except ValueError as e: # pragma: no cover - defensive
117
92
  return model.ToolResultItem(status="error", output=f"Invalid arguments: {e}")
118
93
 
119
94
  file_path = os.path.abspath(args.file_path)
120
95
 
121
96
  # Common file errors
122
- if _is_directory(file_path):
97
+ if is_directory(file_path):
123
98
  return model.ToolResultItem(
124
99
  status="error",
125
100
  output="<tool_use_error>Illegal operation on a directory. edit</tool_use_error>",
@@ -136,40 +111,55 @@ class EditTool(ToolABC):
136
111
 
137
112
  # FileTracker checks (only for editing existing files)
138
113
  file_tracker = get_current_file_tracker()
139
- if not _file_exists(file_path):
114
+ tracked_status: model.FileStatus | None = None
115
+ if not file_exists(file_path):
140
116
  # We require reading before editing
141
117
  return model.ToolResultItem(
142
118
  status="error",
143
119
  output=("File has not been read yet. Read it first before writing to it."),
144
120
  )
145
121
  if file_tracker is not None:
146
- tracked = file_tracker.get(file_path)
147
- if tracked is None:
122
+ tracked_status = file_tracker.get(file_path)
123
+ if tracked_status is None:
148
124
  return model.ToolResultItem(
149
125
  status="error",
150
126
  output=("File has not been read yet. Read it first before writing to it."),
151
127
  )
152
- try:
153
- current_mtime = Path(file_path).stat().st_mtime
154
- except Exception:
155
- current_mtime = tracked
156
- if current_mtime != tracked:
157
- return model.ToolResultItem(
158
- status="error",
159
- output=(
160
- "File has been modified externally. Either by user or a linter. Read it first before writing to it."
161
- ),
162
- )
163
128
 
164
129
  # Edit existing file: validate and apply
165
130
  try:
166
- before = await asyncio.to_thread(_read_text, file_path)
131
+ before = await asyncio.to_thread(read_text, file_path)
167
132
  except FileNotFoundError:
168
133
  return model.ToolResultItem(
169
134
  status="error",
170
135
  output="File has not been read yet. Read it first before writing to it.",
171
136
  )
172
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 OSError:
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
+
173
163
  err = cls.valid(
174
164
  content=before,
175
165
  old_string=args.old_string,
@@ -197,8 +187,8 @@ class EditTool(ToolABC):
197
187
 
198
188
  # Write back
199
189
  try:
200
- await asyncio.to_thread(_write_text, file_path, after)
201
- except Exception as e: # pragma: no cover
190
+ await asyncio.to_thread(write_text, file_path, after)
191
+ except (OSError, UnicodeError) as e: # pragma: no cover
202
192
  return model.ToolResultItem(status="error", output=f"<tool_use_error>{e}</tool_use_error>")
203
193
 
204
194
  # Prepare UI extra: unified diff with 3 context lines
@@ -211,15 +201,18 @@ class EditTool(ToolABC):
211
201
  n=3,
212
202
  )
213
203
  )
214
- diff_text = "\n".join(diff_lines)
215
- ui_extra = model.ToolResultUIExtra(type=model.ToolResultUIExtraType.DIFF_TEXT, diff_text=diff_text)
204
+ ui_extra = build_structured_diff(before, after, file_path=file_path)
216
205
 
217
- # Update tracker with new mtime
206
+ # Update tracker with new mtime and content hash
218
207
  if file_tracker is not None:
219
- try:
220
- file_tracker[file_path] = Path(file_path).stat().st_mtime
221
- except Exception:
222
- pass
208
+ with contextlib.suppress(Exception):
209
+ existing = file_tracker.get(file_path)
210
+ is_mem = existing.is_memory if existing else False
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
+ )
223
216
 
224
217
  # Build output message
225
218
  if args.replace_all:
@@ -238,18 +231,12 @@ class EditTool(ToolABC):
238
231
  header = line
239
232
  plus = header.split("+", 1)[1]
240
233
  plus_range = plus.split(" ")[0]
241
- if "," in plus_range:
242
- start = int(plus_range.split(",")[0])
243
- else:
244
- start = int(plus_range)
234
+ start = int(plus_range.split(",")[0]) if "," in plus_range else int(plus_range)
245
235
  after_line_no = start - 1
246
- except Exception:
236
+ except (ValueError, IndexError):
247
237
  after_line_no = 0
248
238
  continue
249
- if line.startswith(" "):
250
- after_line_no += 1
251
- include_after_line_nos.append(after_line_no)
252
- elif line.startswith("+") and not line.startswith("+++ "):
239
+ if line.startswith(" ") or (line.startswith("+") and not line.startswith("+++ ")):
253
240
  after_line_no += 1
254
241
  include_after_line_nos.append(after_line_no)
255
242
  elif line.startswith("-") and not line.startswith("--- "):
@@ -0,0 +1,41 @@
1
+ Moves a range of lines from one file to another.
2
+
3
+ Usage:
4
+ - Cuts lines from `start_line` to `end_line` (inclusive, 1-indexed) from the source file
5
+ - Pastes them into the target file at `insert_line` (inserted before that line)
6
+ - Both files must have been read first using the Read tool
7
+ - To create a new target file, set `insert_line` to 1 and ensure target file does not exist
8
+ - For same-file moves, line numbers refer to the original file state before any changes
9
+ - Use this tool when refactoring code into separate modules to avoid passing large code blocks twice
10
+ - To move files or directories, use the Bash tool with `mv` command instead
11
+
12
+ Return format:
13
+ The tool returns context snippets showing the state after the operation:
14
+
15
+ 1. Source file context (after cut): Shows 3 lines before and after the cut location
16
+ 2. Target file context (after insert): Shows 3 lines before the inserted content, the inserted content itself, and 3 lines after
17
+
18
+ Example output:
19
+ ```
20
+ Cut 8 lines from /path/source.py (lines 9-16) and pasted into /path/target.py (updated) at line 10.
21
+
22
+ Source file context (after cut):
23
+ 6 return value
24
+ 7
25
+ 8
26
+ -------- cut here --------
27
+ 9 class NextClass:
28
+ 10 pass
29
+ 11
30
+
31
+ Target file context (after insert):
32
+ 7 return {}
33
+ 8
34
+ 9
35
+ -------- inserted --------
36
+ 10 class MovedClass:
37
+ ...
38
+ 17 return result
39
+ -------- end --------
40
+ 18 # Next section
41
+ ```