oc_chat
Loading...
Searching...
No Matches
diffview.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/diffview.py
4
5"""
6Diff-first rendering model for the mutation tools (plan 188 T1).
7
8Purpose
9-------
10Every comparator in the field renders an edit as a **diff**; OC Chat
11rendered ``write_file`` / ``edit_file`` results as a truncated JSON
12blob. This module owns the one shared, framework-free model that turns
13a mutation tool's *call arguments* plus its *result payload* into a
14renderable unified diff, so all three terminal presenters
15(:class:`~oc_ui.terminal.presenter.ConsolePresenter`,
16:class:`~oc_ui.terminal.rich_presenter.RichPresenter`, and the
17:class:`~oc_ui.terminal.enhanced_presenter.EnhancedPresenter` that
18subclasses it) show the same thing and degrade the same way.
19
20Nothing here changes the tool contract. The diff is derived at RENDER
21time from data the render lane already carries: the presenter sees the
22arguments in :meth:`~oc_ui.presenter_base.Presenter.show_tool_call`
23before the tool runs and the result in
24:meth:`~oc_ui.presenter_base.Presenter.show_tool_result` after — this
25module just pairs them (:class:`MutationArgsLedger`) and folds them
26into a diff.
27
28**What the render lane can and cannot know.** ``edit_file`` carries
29both sides of the change (``old_string`` / ``new_string``) plus the
30real ``match_line``, so its diff is a true, file-anchored unified diff.
31``write_file`` carries only the NEW content — the previous contents are
32gone by the time the result arrives — so its render is the full written
33content as additions, and the header says exactly that rather than
34implying a comparison that was never made.
35
36Responsibilities
37----------------
38- :class:`MutationArgsLedger` — FIFO pairing of ``show_tool_call``
39 arguments with the matching ``show_tool_result``.
40- :class:`DiffLine` / :class:`DiffRender` — the pure, presenter-neutral
41 diff model.
42- :func:`build_mutation_diff` — the honesty-gated constructor:
43 returns ``None`` (caller falls back to the legacy JSON line) for a
44 non-mutation tool, an error / gate / confirmation result, unparseable
45 or absent arguments, or a call/result pair whose paths disagree.
46- :func:`diff_header_line` — the one-line summary above the body.
47- :func:`render_diff_plain` — ANSI (or plain) line renderer used by
48 the console presenter; Rich builds its own renderable from the same
49 :class:`DiffRender`.
50
51Diagnostics
52-----------
53Domain: OC_UI.TERMINAL
54Levels:
55 L2 — lifecycle events
56 L3 — semantic details (guard rejections)
57 L4 — deep tracing
58
59Contracts
60---------
61- Framework-free: no ``rich`` / ``prompt_toolkit`` import here, exactly
62 like :mod:`oc_ui.terminal.statusline` — the plain console must render
63 a diff with no optional dependency installed.
64- :func:`build_mutation_diff` NEVER guesses. Any missing or
65 contradictory input returns ``None`` so the caller degrades to the
66 previous rendering rather than showing a diff that might be wrong.
67- Rendered body text is passed through the same secret redaction the
68 read path already applies (:func:`oc_chat.tools._sandbox.redact_secrets`),
69 so a diff is not a secret side-channel. Redaction is best-effort: an
70 import failure degrades to unredacted text rather than losing the
71 render (the content is already on the operator's own disk).
72- Truncation is explicit: :attr:`DiffRender.truncated` is set, the
73 hidden line count is carried, and
74 :attr:`DiffRender.full_content_hint` names the on-disk file where the
75 complete content lives.
76
77Dependencies
78------------
79- oc_diagnostics (structured debug logging via _dbg)
80- oc_chat.tools._sandbox (lazy; redact_secrets for rendered diff body text)
81"""
82from __future__ import annotations
83
84import difflib
85import json
86import re
87from dataclasses import dataclass
88from typing import Any, Iterable
89
90from oc_diagnostics import _dbg
91
92_dbg("OC_UI.TERMINAL", "module", "loaded", 4)
93
94
95#: The tools whose results render as a diff. Deliberately explicit — a new
96#: mutating tool renders as JSON until it is added here WITH a build rule.
97MUTATION_TOOLS: tuple[str, ...] = ("write_file", "edit_file")
98
99#: How many body lines a diff shows before truncating. Beyond this the render
100#: says so and points at the file on disk (which holds the complete content).
101DEFAULT_MAX_DIFF_LINES: int = 60
102
103#: Unified-diff context lines around each hunk.
104_CONTEXT_LINES: int = 3
105
106#: ``@@ -a,b +c,d @@`` — rewritten so the offsets are REAL file line numbers.
107_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
108
109
110@dataclass(frozen=True, slots=True)
112 """One rendered diff line.
113
114 *kind* is one of ``"add"`` / ``"del"`` / ``"ctx"`` / ``"hunk"`` /
115 ``"meta"``; *text* already carries the unified-diff prefix
116 (``+`` / ``-`` / space) so a renderer only has to choose a colour.
117 """
118
119 kind: str
120 text: str
121
122
123@dataclass(frozen=True, slots=True)
125 """A presenter-neutral diff ready to be styled and printed."""
126
127 tool: str
128 """The mutation tool that produced it (``write_file`` / ``edit_file``)."""
129 path: str
130 """The file the change landed in, as the render lane knows it."""
131 lines: tuple[DiffLine, ...]
132 """The (possibly truncated) diff body."""
133 added: int
134 """Total added lines in the WHOLE change, before truncation."""
135 removed: int
136 """Total removed lines in the WHOLE change, before truncation."""
137 truncated: bool
138 """``True`` when :attr:`lines` shows only part of the change."""
139 hidden: int
140 """Body lines not shown; ``0`` when :attr:`truncated` is ``False``."""
141 full_content_hint: str
142 """Where the complete content lives (the file on disk)."""
143 detail: str = ""
144 """Extra header detail (bytes written, replacement count + anchor line)."""
145
146
148 """FIFO pairing of mutation-tool call arguments with their results.
149
150 The ReAct loop announces every tool call (``show_tool_call``) for an
151 iteration *before* dispatching, then renders every result
152 (``show_tool_result``) **in the same order** — so a per-tool-name
153 queue pairs them exactly, with no change to the loop. Only
154 :data:`MUTATION_TOOLS` are recorded, so the ledger stays tiny.
155
156 Pairing is still VERIFIED downstream: :func:`build_mutation_diff`
157 compares the recorded ``path`` against the path the result echoes and
158 refuses to render on disagreement. A mis-pair degrades to the legacy
159 JSON line; it never renders a wrong diff.
160 """
161
162 __slots__ = ("_pending",)
163
164 def __init__(self) -> None:
165 self._pending: dict[str, list[str]] = {}
166
167 def record(self, tool: str, arguments: str | None) -> None:
168 """Remember *arguments* for the next result of *tool* (mutation tools only)."""
169 if tool not in MUTATION_TOOLS or not arguments:
170 return
171 self._pending.setdefault(tool, []).append(arguments)
172
173 def take(self, tool: str) -> str | None:
174 """Pop the oldest recorded arguments for *tool*, or ``None``."""
175 queue = self._pending.get(tool)
176 if not queue:
177 return None
178 return queue.pop(0)
179
180 def clear(self) -> None:
181 """Drop every pending entry — called at turn start so a tool call that
182 never produced a result (an exception mid-dispatch) cannot mis-pair
183 with a later one."""
184 self._pending.clear()
185
186
187def _redact(text: str) -> str:
188 """Apply the shared secret redaction, degrading to *text* if unavailable."""
189 func = "_redact"
190 try:
191 from oc_chat.tools._sandbox import redact_secrets
192 except Exception as exc: # noqa: BLE001 — display must never fail on an import
193 _dbg("OC_UI.TERMINAL", func, f"redaction unavailable: {type(exc).__name__}", 3)
194 return text
195 try:
196 return redact_secrets(text)
197 except Exception as exc: # noqa: BLE001 — same: never lose the render
198 _dbg("OC_UI.TERMINAL", func, f"redaction failed: {type(exc).__name__}", 3)
199 return text
200
201
202def _parse_args(arguments: str | None) -> dict | None:
203 """Parse a tool-call ``arguments`` JSON string into a dict, else ``None``."""
204 if not arguments:
205 return None
206 try:
207 parsed = json.loads(arguments)
208 except (json.JSONDecodeError, TypeError, ValueError):
209 return None
210 return parsed if isinstance(parsed, dict) else None
211
212
213def _norm_path(value: object) -> str:
214 """Normalise a path for comparison: forward slashes, no leading ``./``."""
215 text = str(value or "").replace("\\", "/").strip()
216 while text.startswith("./"):
217 text = text[2:]
218 return text
219
220
221def _paths_agree(arg_path: object, result_paths: Iterable[object]) -> bool:
222 """True when the recorded call path matches a path the result echoes.
223
224 Exact (normalised) match first; a basename match is accepted as the
225 lenient tier because ``write_file``'s ``_tmp`` redirect and the sandbox
226 clamp can legitimately rewrite the directory part. Anything else is a
227 mis-pair and the caller refuses to render.
228 """
229 a = _norm_path(arg_path)
230 if not a:
231 return False
232 candidates = [_norm_path(p) for p in result_paths if p]
233 if not candidates:
234 return False
235 if a in candidates:
236 return True
237 a_base = a.rsplit("/", 1)[-1]
238 return any(a_base == c.rsplit("/", 1)[-1] for c in candidates)
239
240
241def _fmt_bytes(n: int) -> str:
242 """Compact byte count: ``29 B`` / ``1.2 KB`` / ``3.4 MB``."""
243 if n >= 1_000_000:
244 return f"{n / 1_000_000:.1f} MB"
245 if n >= 1_000:
246 return f"{n / 1_000:.1f} KB"
247 return f"{n} B"
248
249
250def _classify(line: str) -> str:
251 """Map a raw unified-diff line to a :class:`DiffLine` kind."""
252 if line.startswith("@@"):
253 return "hunk"
254 if line.startswith("+"):
255 return "add"
256 if line.startswith("-"):
257 return "del"
258 return "ctx"
259
260
261def _reanchor_hunk(line: str, offset: int) -> str:
262 """Shift a ``@@`` hunk header by *offset* so it names REAL file lines."""
263 m = _HUNK_RE.match(line)
264 if m is None:
265 return line
266 old_start, old_len, new_start, new_len, tail = m.groups()
267 old = f"{int(old_start) + offset}" + (f",{old_len}" if old_len else "")
268 new = f"{int(new_start) + offset}" + (f",{new_len}" if new_len else "")
269 return f"@@ -{old} +{new} @@{tail or ''}"
270
271
272def _truncate(body: list[DiffLine], max_lines: int) -> tuple[tuple[DiffLine, ...], bool, int]:
273 """Clip *body* to *max_lines*, reporting whether (and how much) was hidden."""
274 if max_lines <= 0 or len(body) <= max_lines:
275 return tuple(body), False, 0
276 return tuple(body[:max_lines]), True, len(body) - max_lines
277
278
279def _build_edit_diff(args: dict, result: dict, max_lines: int) -> DiffRender | None:
280 """Both sides are present — a true, file-anchored unified diff."""
281 old_string = args.get("old_string")
282 new_string = args.get("new_string")
283 if not isinstance(old_string, str) or not isinstance(new_string, str):
284 return None
285
286 path = _norm_path(result.get("actual_path") or result.get("edited") or args.get("path"))
287 old_lines = _redact(old_string).splitlines()
288 new_lines = _redact(new_string).splitlines()
289
290 raw = list(difflib.unified_diff(old_lines, new_lines, n=_CONTEXT_LINES, lineterm=""))
291 # difflib prepends ``---``/``+++`` file headers; this module renders its own.
292 raw = [ln for ln in raw if not (ln.startswith("---") or ln.startswith("+++"))]
293 if not raw:
294 return None
295
296 try:
297 anchor = int(result.get("match_line") or 1)
298 except (TypeError, ValueError):
299 anchor = 1
300 offset = max(0, anchor - 1)
301
302 body: list[DiffLine] = []
303 added = removed = 0
304 for line in raw:
305 kind = _classify(line)
306 if kind == "hunk":
307 body.append(DiffLine("hunk", _reanchor_hunk(line, offset)))
308 continue
309 if kind == "add":
310 added += 1
311 elif kind == "del":
312 removed += 1
313 body.append(DiffLine(kind, line))
314
315 lines, truncated, hidden = _truncate(body, max_lines)
316 replacements = result.get("replacements")
317 detail = f"@ line {anchor}"
318 if isinstance(replacements, int) and replacements > 1:
319 detail = f"{replacements} replacements, first @ line {anchor}"
320 return DiffRender(
321 tool="edit_file", path=path, lines=lines, added=added, removed=removed,
322 truncated=truncated, hidden=hidden, full_content_hint=path, detail=detail,
323 )
324
325
326def _build_write_diff(args: dict, result: dict, max_lines: int) -> DiffRender | None:
327 """Only the NEW side reaches the render lane — show it as additions."""
328 content = args.get("content")
329 if not isinstance(content, str):
330 return None
331
332 path = _norm_path(result.get("actual_path") or result.get("written") or args.get("path"))
333 lines_text = _redact(content).splitlines()
334 body = [DiffLine("add", "+" + line) for line in lines_text]
335 lines, truncated, hidden = _truncate(body, max_lines)
336
337 size = result.get("size_bytes")
338 detail = "full content shown as additions"
339 if isinstance(size, int):
340 detail = f"{_fmt_bytes(size)} · full content shown as additions"
341 return DiffRender(
342 tool="write_file", path=path, lines=lines, added=len(body), removed=0,
343 truncated=truncated, hidden=hidden, full_content_hint=path, detail=detail,
344 )
345
346
348 tool: str,
349 arguments: str | None,
350 result: Any,
351 *,
352 max_lines: int = DEFAULT_MAX_DIFF_LINES,
353) -> DiffRender | None:
354 """Build a :class:`DiffRender` for a mutation tool, or ``None`` to degrade.
355
356 ``None`` — meaning "render this the old way" — is returned for every
357 case where a diff would be a guess: a tool this module has no rule
358 for, a result that is an error / gate / pending-confirmation rather
359 than an applied change, arguments that are absent or unparseable, and
360 a call/result pair whose paths disagree (a mis-pairing).
361 """
362 func = "build_mutation_diff"
363 if tool not in MUTATION_TOOLS:
364 return None
365 if not isinstance(result, dict):
366 return None
367 # Not an applied change: an error, a gate block, or a pending confirmation.
368 if result.get("error") is not None or result.get("gate") or result.get("requires_confirmation"):
369 _dbg("OC_UI.TERMINAL", func, f"{tool}: non-applied result; no diff", 3)
370 return None
371
372 args = _parse_args(arguments)
373 if args is None:
374 _dbg("OC_UI.TERMINAL", func, f"{tool}: no usable arguments; no diff", 3)
375 return None
376
377 result_paths = (result.get("original_path"), result.get("actual_path"),
378 result.get("written"), result.get("edited"), result.get("path"))
379 if not _paths_agree(args.get("path"), result_paths):
380 _dbg("OC_UI.TERMINAL", func, f"{tool}: call/result path mismatch; no diff", 3)
381 return None
382
383 if tool == "edit_file":
384 return _build_edit_diff(args, result, max_lines)
385 return _build_write_diff(args, result, max_lines)
386
387
388#: Argument fields whose payload the diff below already shows in full, so the
389#: pre-dispatch announce line renders them as a size marker instead.
390_ELIDED_ARG_FIELDS: tuple[str, ...] = ("content", "old_string", "new_string")
391
392
393def _elide(value: str) -> str:
394 """``<12 lines, 1.2 KB>`` — a size marker standing in for a bulky payload."""
395 n_lines = len(value.splitlines()) or (1 if value else 0)
396 return f"<{n_lines} line(s), {_fmt_bytes(len(value.encode('utf-8', 'replace')))}>"
397
398
399def summarize_mutation_call(tool: str, arguments: str | None) -> str | None:
400 """Compact ``show_tool_call`` argument rendering for a mutation tool.
401
402 ``show_tool_call`` fires BEFORE dispatch, so a large ``write_file``
403 used to dump its entire ``content`` into scrollback and *then* render
404 the diff underneath — the announce line defeated the diff. For
405 :data:`MUTATION_TOOLS` the bulky fields (:data:`_ELIDED_ARG_FIELDS`)
406 become size markers; every other field renders normally, so the path,
407 the expected count and any future flag stay visible.
408
409 Returns ``None`` for a non-mutation tool or unparseable arguments —
410 the caller then renders the arguments exactly as before.
411 """
412 if tool not in MUTATION_TOOLS:
413 return None
414 args = _parse_args(arguments)
415 if args is None:
416 return None
417 parts: list[str] = []
418 for key, value in args.items():
419 if key in _ELIDED_ARG_FIELDS and isinstance(value, str):
420 parts.append(f"{key}={_elide(value)}")
421 else:
422 parts.append(f"{key}={json.dumps(value, ensure_ascii=False)}")
423 return ", ".join(parts)
424
425
426def diff_header_line(diff: DiffRender) -> str:
427 """The one-line summary rendered above the diff body."""
428 counts = f"+{diff.added}"
429 if diff.removed:
430 counts += f" -{diff.removed}"
431 detail = f" · {diff.detail}" if diff.detail else ""
432 return f"{diff.tool} · {diff.path} ({counts}{detail})"
433
434
435def diff_footer_line(diff: DiffRender) -> str | None:
436 """The truncation notice, or ``None`` when the whole diff is shown."""
437 if not diff.truncated:
438 return None
439 return (f"… +{diff.hidden} more line(s) not shown — "
440 f"full content on disk at {diff.full_content_hint}")
441
442
443# ─── Plain-ANSI rendering (the console presenter; Rich builds its own) ────────
444
445_ANSI = {
446 "reset": "\033[0m",
447 "dim": "\033[2m",
448 "add": "\033[32m",
449 "del": "\033[31m",
450 "hunk": "\033[36m",
451}
452
453
454def render_diff_plain(diff: DiffRender | None, *, color: bool = True) -> list[str]:
455 """Render *diff* as a list of terminal lines (ANSI-styled when *color*).
456
457 Returns ``[]`` for ``None`` so a caller can branch on emptiness without a
458 second null check.
459 """
460 if diff is None:
461 return []
462
463 def style(key: str, text: str) -> str:
464 if not color:
465 return text
466 return f"{_ANSI[key]}{text}{_ANSI['reset']}"
467
468 out: list[str] = [style("dim", diff_header_line(diff))]
469 for line in diff.lines:
470 if line.kind == "add":
471 out.append(style("add", line.text))
472 elif line.kind == "del":
473 out.append(style("del", line.text))
474 elif line.kind == "hunk":
475 out.append(style("hunk", line.text))
476 else:
477 out.append(line.text)
478 footer = diff_footer_line(diff)
479 if footer:
480 out.append(style("dim", footer))
481 return out
482
483
484__all__ = [
485 "MUTATION_TOOLS",
486 "DEFAULT_MAX_DIFF_LINES",
487 "DiffLine",
488 "DiffRender",
489 "MutationArgsLedger",
490 "build_mutation_diff",
491 "diff_header_line",
492 "diff_footer_line",
493 "render_diff_plain",
494 "summarize_mutation_call",
495]
None record(self, str tool, str|None arguments)
Definition diffview.py:167
DiffRender|None build_mutation_diff(str tool, str|None arguments, Any result, *, int max_lines=DEFAULT_MAX_DIFF_LINES)
Definition diffview.py:353
str diff_header_line(DiffRender diff)
Definition diffview.py:426
str|None diff_footer_line(DiffRender diff)
Definition diffview.py:435
list[str] render_diff_plain(DiffRender|None diff, *, bool color=True)
Definition diffview.py:454
DiffRender|None _build_edit_diff(dict args, dict result, int max_lines)
Definition diffview.py:279
str _redact(str text)
Definition diffview.py:187
str _elide(str value)
Definition diffview.py:393
str _reanchor_hunk(str line, int offset)
Definition diffview.py:261
str _norm_path(object value)
Definition diffview.py:213
dict|None _parse_args(str|None arguments)
Definition diffview.py:202
DiffRender|None _build_write_diff(dict args, dict result, int max_lines)
Definition diffview.py:326
str _classify(str line)
Definition diffview.py:250
str|None summarize_mutation_call(str tool, str|None arguments)
Definition diffview.py:399
tuple[tuple[DiffLine,...], bool, int] _truncate(list[DiffLine] body, int max_lines)
Definition diffview.py:272
bool _paths_agree(object arg_path, Iterable[object] result_paths)
Definition diffview.py:221