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
@@ -2,6 +2,7 @@ import asyncio
2
2
  import contextlib
3
3
  import os
4
4
  import re
5
+ import shlex
5
6
  import signal
6
7
  import subprocess
7
8
  from pathlib import Path
@@ -12,6 +13,7 @@ from pydantic import BaseModel
12
13
  from klaude_code import const
13
14
  from klaude_code.core.tool.shell.command_safety import is_safe_command
14
15
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
16
+ from klaude_code.core.tool.tool_context import get_current_file_tracker
15
17
  from klaude_code.core.tool.tool_registry import register
16
18
  from klaude_code.protocol import llm_param, model, tools
17
19
 
@@ -117,6 +119,149 @@ class BashTool(ToolABC):
117
119
  }
118
120
  )
119
121
 
122
+ def _hash_file_content_sha256(file_path: str) -> str | None:
123
+ try:
124
+ suffix = Path(file_path).suffix.lower()
125
+ if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
126
+ import hashlib
127
+
128
+ with open(file_path, "rb") as f:
129
+ return hashlib.sha256(f.read()).hexdigest()
130
+
131
+ import hashlib
132
+
133
+ hasher = hashlib.sha256()
134
+ with open(file_path, encoding="utf-8", errors="replace") as f:
135
+ for line in f:
136
+ hasher.update(line.encode("utf-8"))
137
+ return hasher.hexdigest()
138
+ except (FileNotFoundError, IsADirectoryError, OSError, PermissionError, UnicodeDecodeError):
139
+ return None
140
+
141
+ def _resolve_in_dir(base_dir: str, path: str) -> str:
142
+ if os.path.isabs(path):
143
+ return os.path.abspath(path)
144
+ return os.path.abspath(os.path.join(base_dir, path))
145
+
146
+ def _track_files_read(file_paths: list[str], *, base_dir: str) -> None:
147
+ file_tracker = get_current_file_tracker()
148
+ if file_tracker is None:
149
+ return
150
+ for p in file_paths:
151
+ abs_path = _resolve_in_dir(base_dir, p)
152
+ if not os.path.exists(abs_path) or os.path.isdir(abs_path):
153
+ continue
154
+ sha = _hash_file_content_sha256(abs_path)
155
+ if sha is None:
156
+ continue
157
+ existing = file_tracker.get(abs_path)
158
+ is_mem = existing.is_memory if existing else False
159
+ with contextlib.suppress(Exception):
160
+ file_tracker[abs_path] = model.FileStatus(
161
+ mtime=Path(abs_path).stat().st_mtime,
162
+ content_sha256=sha,
163
+ is_memory=is_mem,
164
+ )
165
+
166
+ def _track_files_written(file_paths: list[str], *, base_dir: str) -> None:
167
+ # Same as read tracking, but intentionally kept separate for clarity.
168
+ _track_files_read(file_paths, base_dir=base_dir)
169
+
170
+ def _track_mv(src_paths: list[str], dest_path: str, *, base_dir: str) -> None:
171
+ file_tracker = get_current_file_tracker()
172
+ if file_tracker is None:
173
+ return
174
+
175
+ abs_dest = _resolve_in_dir(base_dir, dest_path)
176
+ dest_is_dir = os.path.isdir(abs_dest)
177
+
178
+ for src in src_paths:
179
+ abs_src = _resolve_in_dir(base_dir, src)
180
+ abs_new = os.path.join(abs_dest, os.path.basename(abs_src)) if dest_is_dir else abs_dest
181
+
182
+ # Remove old entry if present.
183
+ existing = file_tracker.pop(abs_src, None)
184
+ is_mem = existing.is_memory if existing else False
185
+
186
+ if not os.path.exists(abs_new) or os.path.isdir(abs_new):
187
+ continue
188
+
189
+ sha = _hash_file_content_sha256(abs_new)
190
+ if sha is None:
191
+ continue
192
+ with contextlib.suppress(Exception):
193
+ file_tracker[abs_new] = model.FileStatus(
194
+ mtime=Path(abs_new).stat().st_mtime,
195
+ content_sha256=sha,
196
+ is_memory=is_mem,
197
+ )
198
+
199
+ def _best_effort_update_file_tracker(command: str) -> None:
200
+ # Best-effort heuristics for common shell tools that access/modify files.
201
+ # We intentionally do not try to interpret complex shell scripts here.
202
+ try:
203
+ argv = shlex.split(command, posix=True)
204
+ except ValueError:
205
+ return
206
+ if not argv:
207
+ return
208
+
209
+ # Handle common patterns like: cd subdir && cat file
210
+ base_dir = os.getcwd()
211
+ while len(argv) >= 4 and argv[0] == "cd" and argv[2] == "&&":
212
+ dest = argv[1]
213
+ if dest != "-":
214
+ base_dir = _resolve_in_dir(base_dir, dest)
215
+ argv = argv[3:]
216
+ if not argv:
217
+ return
218
+
219
+ cmd0 = argv[0]
220
+ if cmd0 == "cat":
221
+ paths = [a for a in argv[1:] if a and not a.startswith("-") and a != "-"]
222
+ _track_files_read(paths, base_dir=base_dir)
223
+ return
224
+
225
+ if cmd0 == "sed":
226
+ # Support: sed [-i ...] 's/old/new/' file1 [file2 ...]
227
+ # and: sed -n 'Np' file
228
+ saw_script = False
229
+ file_paths: list[str] = []
230
+ for a in argv[1:]:
231
+ if not a:
232
+ continue
233
+ if a == "--":
234
+ continue
235
+ if a.startswith("-") and not saw_script:
236
+ continue
237
+ if not saw_script and (a.startswith("s/") or a.startswith("s|") or a.endswith("p")):
238
+ saw_script = True
239
+ continue
240
+ if saw_script and not a.startswith("-"):
241
+ file_paths.append(a)
242
+
243
+ if file_paths:
244
+ _track_files_written(file_paths, base_dir=base_dir)
245
+ return
246
+
247
+ if cmd0 == "mv":
248
+ # Support: mv [opts] src... dest
249
+ operands: list[str] = []
250
+ end_of_opts = False
251
+ for a in argv[1:]:
252
+ if not end_of_opts and a == "--":
253
+ end_of_opts = True
254
+ continue
255
+ if not end_of_opts and a.startswith("-"):
256
+ continue
257
+ operands.append(a)
258
+ if len(operands) < 2:
259
+ return
260
+ srcs = operands[:-1]
261
+ dest = operands[-1]
262
+ _track_mv(srcs, dest, base_dir=base_dir)
263
+ return
264
+
120
265
  async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
121
266
  # Best-effort termination. Ensure we don't hang on cancellation.
122
267
  if proc.returncode is not None:
@@ -185,6 +330,8 @@ class BashTool(ToolABC):
185
330
  # Include stderr if there is useful diagnostics despite success
186
331
  if stderr.strip():
187
332
  output = (output + ("\n" if output else "")) + f"[stderr]\n{stderr}"
333
+
334
+ _best_effort_update_file_tracker(args.command)
188
335
  return model.ToolResultItem(
189
336
  status="success",
190
337
  output=output.strip(),
@@ -4,7 +4,6 @@ from enum import Enum
4
4
  class CommandName(str, Enum):
5
5
  INIT = "init"
6
6
  DEBUG = "debug"
7
- DIFF = "diff"
8
7
  HELP = "help"
9
8
  MODEL = "model"
10
9
  COMPACT = "compact"
@@ -70,9 +70,15 @@ class TodoItem(BaseModel):
70
70
 
71
71
 
72
72
  class FileStatus(BaseModel):
73
- """Tracks file state including modification time and memory file flag."""
73
+ """Tracks file state including modification time and content hash.
74
+
75
+ Notes:
76
+ - `mtime` is a cheap heuristic and may miss changes on some filesystems.
77
+ - `content_sha256` provides an explicit content-based change detector.
78
+ """
74
79
 
75
80
  mtime: float
81
+ content_sha256: str | None = None
76
82
  is_memory: bool = False
77
83
 
78
84
 
@@ -86,9 +92,27 @@ class ToolSideEffect(str, Enum):
86
92
 
87
93
 
88
94
  # Discriminated union types for ToolResultUIExtra
89
- class DiffTextUIExtra(BaseModel):
90
- type: Literal["diff_text"] = "diff_text"
91
- diff_text: str
95
+ class DiffSpan(BaseModel):
96
+ op: Literal["equal", "insert", "delete"]
97
+ text: str
98
+
99
+
100
+ class DiffLine(BaseModel):
101
+ kind: Literal["ctx", "add", "remove", "gap"]
102
+ new_line_no: int | None = None
103
+ spans: list[DiffSpan]
104
+
105
+
106
+ class DiffFileDiff(BaseModel):
107
+ file_path: str
108
+ lines: list[DiffLine]
109
+ stats_add: int = 0
110
+ stats_remove: int = 0
111
+
112
+
113
+ class DiffUIExtra(BaseModel):
114
+ type: Literal["diff"] = "diff"
115
+ files: list[DiffFileDiff]
92
116
 
93
117
 
94
118
  class TodoListUIExtra(BaseModel):
@@ -122,12 +146,7 @@ class SessionStatusUIExtra(BaseModel):
122
146
 
123
147
 
124
148
  ToolResultUIExtra = Annotated[
125
- DiffTextUIExtra
126
- | TodoListUIExtra
127
- | SessionIdUIExtra
128
- | MermaidLinkUIExtra
129
- | TruncationUIExtra
130
- | SessionStatusUIExtra,
149
+ DiffUIExtra | TodoListUIExtra | SessionIdUIExtra | MermaidLinkUIExtra | TruncationUIExtra | SessionStatusUIExtra,
131
150
  Field(discriminator="type"),
132
151
  ]
133
152
 
@@ -1,14 +1,13 @@
1
1
  BASH = "Bash"
2
2
  APPLY_PATCH = "apply_patch"
3
3
  EDIT = "Edit"
4
- MULTI_EDIT = "MultiEdit"
4
+
5
5
  READ = "Read"
6
6
  WRITE = "Write"
7
7
  TODO_WRITE = "TodoWrite"
8
8
  UPDATE_PLAN = "update_plan"
9
9
  SKILL = "Skill"
10
10
  MERMAID = "Mermaid"
11
- MEMORY = "Memory"
12
11
  WEB_FETCH = "WebFetch"
13
12
  WEB_SEARCH = "WebSearch"
14
13
  REPORT_BACK = "report_back"
@@ -362,33 +362,87 @@ def _should_collapse(text: str) -> bool:
362
362
  return text.count("\n") + 1 > _COLLAPSIBLE_LINE_THRESHOLD or len(text) > _COLLAPSIBLE_CHAR_THRESHOLD
363
363
 
364
364
 
365
- def _render_diff_block(diff: str) -> str:
366
- lines = diff.splitlines()
365
+ def _render_diff_block(diff: model.DiffUIExtra) -> str:
367
366
  rendered: list[str] = []
368
- for line in lines:
369
- escaped = _escape_html(line)
370
- if line.startswith("+"):
371
- rendered.append(f'<span class="diff-line diff-plus">{escaped}</span>')
372
- elif line.startswith("-"):
373
- rendered.append(f'<span class="diff-line diff-minus">{escaped}</span>')
374
- else:
375
- rendered.append(f'<span class="diff-line diff-ctx">{escaped}</span>')
367
+ line_count = 0
368
+
369
+ for file_diff in diff.files:
370
+ header = _render_diff_file_header(file_diff)
371
+ if header:
372
+ rendered.append(header)
373
+ for line in file_diff.lines:
374
+ rendered.append(_render_diff_line(line))
375
+ line_count += 1
376
+
377
+ if line_count == 0:
378
+ rendered.append('<span class="diff-line diff-ctx">&nbsp;</span>')
379
+
376
380
  diff_content = f'<div class="diff-view">{"".join(rendered)}</div>'
377
- open_attr = "" if _should_collapse(diff) else " open"
381
+ open_attr = "" if _should_collapse("\n" * max(1, line_count)) else " open"
378
382
  return (
379
383
  f'<details class="diff-collapsible"{open_attr}>'
380
- f"<summary>Diff ({len(lines)} lines)</summary>"
384
+ f"<summary>Diff ({line_count} lines)</summary>"
381
385
  f"{diff_content}"
382
386
  "</details>"
383
387
  )
384
388
 
385
389
 
386
- def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
387
- if isinstance(ui_extra, model.DiffTextUIExtra):
388
- return ui_extra.diff_text
390
+ def _render_diff_file_header(file_diff: model.DiffFileDiff) -> str:
391
+ stats_parts: list[str] = []
392
+ if file_diff.stats_add > 0:
393
+ stats_parts.append(f'<span class="diff-stats-add">+{file_diff.stats_add}</span>')
394
+ if file_diff.stats_remove > 0:
395
+ stats_parts.append(f'<span class="diff-stats-remove">-{file_diff.stats_remove}</span>')
396
+ stats_html = f' <span class="diff-stats">{" ".join(stats_parts)}</span>' if stats_parts else ""
397
+ file_name = _escape_html(file_diff.file_path)
398
+ return f'<div class="diff-file">{file_name}{stats_html}</div>'
399
+
400
+
401
+ def _render_diff_line(line: model.DiffLine) -> str:
402
+ if line.kind == "gap":
403
+ line_class = "diff-ctx"
404
+ prefix = "⋮"
405
+ else:
406
+ line_class = "diff-plus" if line.kind == "add" else "diff-minus" if line.kind == "remove" else "diff-ctx"
407
+ prefix = "+" if line.kind == "add" else "-" if line.kind == "remove" else " "
408
+ spans = [_render_diff_span(span, line.kind) for span in line.spans]
409
+ content = "".join(spans)
410
+ if not content:
411
+ content = "&nbsp;"
412
+ return f'<span class="diff-line {line_class}">{prefix} {content}</span>'
413
+
414
+
415
+ def _render_diff_span(span: model.DiffSpan, line_kind: str) -> str:
416
+ text = _escape_html(span.text)
417
+ if line_kind == "add" and span.op == "insert":
418
+ return f'<span class="diff-span diff-char-add">{text}</span>'
419
+ if line_kind == "remove" and span.op == "delete":
420
+ return f'<span class="diff-span diff-char-remove">{text}</span>'
421
+ return f'<span class="diff-span">{text}</span>'
422
+
423
+
424
+ def _get_diff_ui_extra(ui_extra: model.ToolResultUIExtra | None) -> model.DiffUIExtra | None:
425
+ if isinstance(ui_extra, model.DiffUIExtra):
426
+ return ui_extra
389
427
  return None
390
428
 
391
429
 
430
+ def _build_add_only_diff(text: str, file_path: str) -> model.DiffUIExtra:
431
+ lines: list[model.DiffLine] = []
432
+ new_line_no = 1
433
+ for line in text.splitlines():
434
+ lines.append(
435
+ model.DiffLine(
436
+ kind="add",
437
+ new_line_no=new_line_no,
438
+ spans=[model.DiffSpan(op="equal", text=line)],
439
+ )
440
+ )
441
+ new_line_no += 1
442
+ file_diff = model.DiffFileDiff(file_path=file_path, lines=lines, stats_add=len(lines), stats_remove=0)
443
+ return model.DiffUIExtra(files=[file_diff])
444
+
445
+
392
446
  def _get_mermaid_link_html(
393
447
  ui_extra: model.ToolResultUIExtra | None, tool_call: model.ToolCallItem | None = None
394
448
  ) -> str | None:
@@ -513,18 +567,19 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
513
567
  ]
514
568
 
515
569
  if result:
516
- diff_text = _get_diff_text(result.ui_extra)
570
+ diff_ui = _get_diff_ui_extra(result.ui_extra)
517
571
  mermaid_html = _get_mermaid_link_html(result.ui_extra, tool_call)
518
572
 
519
573
  should_hide_text = tool_call.name in ("TodoWrite", "update_plan") and result.status != "error"
520
574
 
521
- if tool_call.name == "Edit" and not diff_text and result.status != "error":
575
+ if tool_call.name == "Edit" and not diff_ui and result.status != "error":
522
576
  try:
523
577
  args_data = json.loads(tool_call.arguments)
578
+ file_path = args_data.get("file_path", "Unknown file")
524
579
  old_string = args_data.get("old_string", "")
525
580
  new_string = args_data.get("new_string", "")
526
581
  if old_string == "" and new_string:
527
- diff_text = "\n".join(f"+{line}" for line in new_string.splitlines())
582
+ diff_ui = _build_add_only_diff(new_string, file_path)
528
583
  except (json.JSONDecodeError, TypeError):
529
584
  pass
530
585
 
@@ -536,8 +591,8 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
536
591
  else:
537
592
  items_to_render.append(_render_text_block(result.output))
538
593
 
539
- if diff_text:
540
- items_to_render.append(_render_diff_block(diff_text))
594
+ if diff_ui:
595
+ items_to_render.append(_render_diff_block(diff_ui))
541
596
 
542
597
  if mermaid_html:
543
598
  items_to_render.append(mermaid_html)
@@ -31,6 +31,8 @@
31
31
  --bg-overlay: rgba(248, 246, 240, 0.98);
32
32
  --bg-error: #fdecec;
33
33
  --bg-success: #eaf6ed;
34
+ --bg-error-strong: #f9d6d6;
35
+ --bg-success-strong: #d5efdd;
34
36
  --bg-code: #f7f7f4;
35
37
  --border: #ded8cf;
36
38
  --text: #151515;
@@ -815,6 +817,21 @@
815
817
  overflow-x: auto;
816
818
  border: 1px solid var(--border);
817
819
  }
820
+ .diff-file {
821
+ color: var(--accent);
822
+ font-weight: 700;
823
+ margin: 6px 0 4px 0;
824
+ font-size: var(--font-size-sm);
825
+ }
826
+ .diff-stats {
827
+ font-weight: 600;
828
+ }
829
+ .diff-stats-add {
830
+ color: var(--success);
831
+ }
832
+ .diff-stats-remove {
833
+ color: var(--error);
834
+ }
818
835
  .diff-line {
819
836
  white-space: pre;
820
837
  }
@@ -833,6 +850,17 @@
833
850
  opacity: 0.7;
834
851
  display: block;
835
852
  }
853
+ .diff-span {
854
+ white-space: pre;
855
+ }
856
+ .diff-char-add {
857
+ background: var(--bg-success-strong);
858
+ font-weight: 600;
859
+ }
860
+ .diff-char-remove {
861
+ background: var(--bg-error-strong);
862
+ font-weight: 600;
863
+ }
836
864
 
837
865
  /* Collapsible Diff View */
838
866
  details.diff-collapsible {
@@ -31,16 +31,20 @@ def truncate_display(
31
31
  return Text(f"… (more {remaining} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED)
32
32
 
33
33
  lines = text.split("\n")
34
- extra_lines = 0
35
- if len(lines) > max_lines:
36
- extra_lines = len(lines) - max_lines
37
- lines = lines[:max_lines]
34
+ truncated_lines = 0
35
+ head_lines: list[str] = []
36
+ tail_lines: list[str] = []
38
37
 
39
- out = Text()
40
- if base_style is not None:
41
- out.style = base_style
38
+ if len(lines) > max_lines:
39
+ truncated_lines = len(lines) - max_lines
40
+ head_count = max_lines // 2
41
+ tail_count = max_lines - head_count
42
+ head_lines = lines[:head_count]
43
+ tail_lines = lines[-tail_count:]
44
+ else:
45
+ head_lines = lines
42
46
 
43
- for idx, line in enumerate(lines):
47
+ def append_line(out: Text, line: str) -> None:
44
48
  if len(line) > max_line_length:
45
49
  extra_chars = len(line) - max_line_length
46
50
  out.append(line[:max_line_length])
@@ -53,10 +57,21 @@ def truncate_display(
53
57
  else:
54
58
  out.append(line)
55
59
 
56
- if idx != len(lines) - 1 or extra_lines > 0:
60
+ out = Text()
61
+ if base_style is not None:
62
+ out.style = base_style
63
+
64
+ for idx, line in enumerate(head_lines):
65
+ append_line(out, line)
66
+ if idx < len(head_lines) - 1 or truncated_lines > 0 or tail_lines:
57
67
  out.append("\n")
58
68
 
59
- if extra_lines > 0:
60
- out.append_text(Text(f" (more {extra_lines} lines)", style=ThemeKey.TOOL_RESULT_TRUNCATED))
69
+ if truncated_lines > 0:
70
+ out.append_text(Text(f" (more {truncated_lines} lines)\n", style=ThemeKey.TOOL_RESULT_TRUNCATED))
71
+
72
+ for idx, line in enumerate(tail_lines):
73
+ append_line(out, line)
74
+ if idx < len(tail_lines) - 1:
75
+ out.append("\n")
61
76
 
62
77
  return out
@@ -4,7 +4,6 @@ from rich.table import Table
4
4
  from rich.text import Text
5
5
 
6
6
  from klaude_code.protocol import commands, events, model
7
- from klaude_code.ui.renderers import diffs as r_diffs
8
7
  from klaude_code.ui.renderers.common import create_grid, truncate_display
9
8
  from klaude_code.ui.renderers.tools import render_path
10
9
  from klaude_code.ui.rich.markdown import NoInsetMarkdown
@@ -103,10 +102,6 @@ def render_command_output(e: events.DeveloperMessageEvent) -> RenderableType:
103
102
  return Text("")
104
103
 
105
104
  match e.item.command_output.command_name:
106
- case commands.CommandName.DIFF:
107
- if e.item.content is None or len(e.item.content) == 0:
108
- return Padding.indent(Text("(no changes)", style=ThemeKey.TOOL_RESULT), level=2)
109
- return r_diffs.render_diff_panel(e.item.content, show_file_name=True)
110
105
  case commands.CommandName.HELP:
111
106
  return Padding.indent(Text.from_markup(e.item.content or ""), level=2)
112
107
  case commands.CommandName.STATUS:
@@ -5,6 +5,7 @@ from rich.panel import Panel
5
5
  from rich.text import Text
6
6
 
7
7
  from klaude_code import const
8
+ from klaude_code.protocol import model
8
9
  from klaude_code.ui.renderers.common import create_grid
9
10
  from klaude_code.ui.rich.theme import ThemeKey
10
11
 
@@ -179,6 +180,30 @@ def render_diff(diff_text: str, show_file_name: bool = False) -> RenderableType:
179
180
  return grid
180
181
 
181
182
 
183
+ def render_structured_diff(ui_extra: model.DiffUIExtra, show_file_name: bool = False) -> RenderableType:
184
+ files = ui_extra.files
185
+ if not files:
186
+ return Text("")
187
+
188
+ grid = create_grid()
189
+ grid.padding = (0, 0)
190
+ show_headers = show_file_name or len(files) > 1
191
+
192
+ for idx, file_diff in enumerate(files):
193
+ if idx > 0:
194
+ grid.add_row("", "")
195
+
196
+ if show_headers:
197
+ grid.add_row(*_render_file_header(file_diff))
198
+
199
+ for line in file_diff.lines:
200
+ prefix = _make_structured_prefix(line, const.DIFF_PREFIX_WIDTH)
201
+ text = _render_structured_line(line)
202
+ grid.add_row(Text(prefix, ThemeKey.TOOL_RESULT), text)
203
+
204
+ return grid
205
+
206
+
182
207
  def render_diff_panel(
183
208
  diff_text: str,
184
209
  *,
@@ -210,3 +235,62 @@ def render_diff_panel(
210
235
  if indent <= 0:
211
236
  return panel
212
237
  return Padding.indent(panel, level=indent)
238
+
239
+
240
+ def _render_file_header(file_diff: model.DiffFileDiff) -> tuple[Text, Text]:
241
+ file_text = Text(file_diff.file_path, style=ThemeKey.DIFF_FILE_NAME)
242
+ stats_text = Text()
243
+ if file_diff.stats_add > 0:
244
+ stats_text.append(f"+{file_diff.stats_add}", style=ThemeKey.DIFF_STATS_ADD)
245
+ if file_diff.stats_remove > 0:
246
+ if stats_text.plain:
247
+ stats_text.append(" ")
248
+ stats_text.append(f"-{file_diff.stats_remove}", style=ThemeKey.DIFF_STATS_REMOVE)
249
+
250
+ file_line = Text(style=ThemeKey.DIFF_FILE_NAME)
251
+ file_line.append_text(file_text)
252
+ if stats_text.plain:
253
+ file_line.append(" (")
254
+ file_line.append_text(stats_text)
255
+ file_line.append(")")
256
+
257
+ if file_diff.stats_add > 0 and file_diff.stats_remove == 0:
258
+ file_mark = "+"
259
+ elif file_diff.stats_remove > 0 and file_diff.stats_add == 0:
260
+ file_mark = "-"
261
+ else:
262
+ file_mark = "±"
263
+
264
+ prefix = Text(f"{file_mark:>{const.DIFF_PREFIX_WIDTH}} ", style=ThemeKey.DIFF_FILE_NAME)
265
+ return prefix, file_line
266
+
267
+
268
+ def _make_structured_prefix(line: model.DiffLine, width: int) -> str:
269
+ if line.kind == "gap":
270
+ return f"{'⋮':>{width}} "
271
+ number = " " * width
272
+ if line.kind in {"add", "ctx"} and line.new_line_no is not None:
273
+ number = f"{line.new_line_no:>{width}}"
274
+ marker = "+" if line.kind == "add" else "-" if line.kind == "remove" else " "
275
+ return f"{number} {marker}"
276
+
277
+
278
+ def _render_structured_line(line: model.DiffLine) -> Text:
279
+ if line.kind == "gap":
280
+ return Text("")
281
+ text = Text()
282
+ for span in line.spans:
283
+ text.append(span.text, style=_span_style(line.kind, span.op))
284
+ return text
285
+
286
+
287
+ def _span_style(line_kind: str, span_op: str) -> ThemeKey:
288
+ if line_kind == "add":
289
+ if span_op == "insert":
290
+ return ThemeKey.DIFF_ADD_CHAR
291
+ return ThemeKey.DIFF_ADD
292
+ if line_kind == "remove":
293
+ if span_op == "delete":
294
+ return ThemeKey.DIFF_REMOVE_CHAR
295
+ return ThemeKey.DIFF_REMOVE
296
+ return ThemeKey.TOOL_RESULT