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.
- klaude_code/cli/debug.py +8 -10
- klaude_code/command/__init__.py +0 -3
- klaude_code/command/prompt-deslop.md +1 -1
- 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 +147 -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/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.21.dist-info → klaude_code-1.2.22.dist-info}/METADATA +2 -1
- {klaude_code-1.2.21.dist-info → klaude_code-1.2.22.dist-info}/RECORD +32 -35
- 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.21.dist-info → klaude_code-1.2.22.dist-info}/WHEEL +0 -0
- {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(),
|
klaude_code/protocol/commands.py
CHANGED
klaude_code/protocol/model.py
CHANGED
|
@@ -70,9 +70,15 @@ class TodoItem(BaseModel):
|
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
class FileStatus(BaseModel):
|
|
73
|
-
"""Tracks file state including modification time and
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
klaude_code/protocol/tools.py
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
BASH = "Bash"
|
|
2
2
|
APPLY_PATCH = "apply_patch"
|
|
3
3
|
EDIT = "Edit"
|
|
4
|
-
|
|
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"
|
klaude_code/session/export.py
CHANGED
|
@@ -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:
|
|
366
|
-
lines = diff.splitlines()
|
|
365
|
+
def _render_diff_block(diff: model.DiffUIExtra) -> str:
|
|
367
366
|
rendered: list[str] = []
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
rendered.append(
|
|
374
|
-
|
|
375
|
-
rendered.append(
|
|
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"> </span>')
|
|
379
|
+
|
|
376
380
|
diff_content = f'<div class="diff-view">{"".join(rendered)}</div>'
|
|
377
|
-
open_attr = "" if _should_collapse(
|
|
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 ({
|
|
384
|
+
f"<summary>Diff ({line_count} lines)</summary>"
|
|
381
385
|
f"{diff_content}"
|
|
382
386
|
"</details>"
|
|
383
387
|
)
|
|
384
388
|
|
|
385
389
|
|
|
386
|
-
def
|
|
387
|
-
|
|
388
|
-
|
|
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 = " "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
540
|
-
items_to_render.append(_render_diff_block(
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
lines = lines[:max_lines]
|
|
34
|
+
truncated_lines = 0
|
|
35
|
+
head_lines: list[str] = []
|
|
36
|
+
tail_lines: list[str] = []
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
out.append_text(Text(f"
|
|
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
|