6Diff-first rendering model for the mutation tools (plan 188 T1).
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.
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
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.
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
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
56 L3 — semantic details (guard rejections)
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.
79- oc_diagnostics (structured debug logging via _dbg)
80- oc_chat.tools._sandbox (lazy; redact_secrets for rendered diff body text)
82from __future__
import annotations
87from dataclasses
import dataclass
88from typing
import Any, Iterable
90from oc_diagnostics
import _dbg
92_dbg(
"OC_UI.TERMINAL",
"module",
"loaded", 4)
97MUTATION_TOOLS: tuple[str, ...] = (
"write_file",
"edit_file")
101DEFAULT_MAX_DIFF_LINES: int = 60
104_CONTEXT_LINES: int = 3
107_HUNK_RE = re.compile(
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
110@dataclass(frozen=True, slots=True)
112 """One rendered diff line.
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.
123@dataclass(frozen=True, slots=True)
125 """A presenter-neutral diff ready to be styled and printed."""
128 """The mutation tool that produced it (``write_file`` / ``edit_file``)."""
130 """The file the change landed in, as the render lane knows it."""
131 lines: tuple[DiffLine, ...]
132 """The (possibly truncated) diff body."""
134 """Total added lines in the WHOLE change, before truncation."""
136 """Total removed lines in the WHOLE change, before truncation."""
138 """``True`` when :attr:`lines` shows only part of the change."""
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)."""
144 """Extra header detail (bytes written, replacement count + anchor line)."""
148 """FIFO pairing of mutation-tool call arguments with their results.
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.
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.
162 __slots__ = (
"_pending",)
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:
171 self.
_pending.setdefault(tool, []).append(arguments)
173 def take(self, tool: str) -> str |
None:
174 """Pop the oldest recorded arguments for *tool*, or ``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
188 """Apply the shared secret redaction, degrading to *text* if unavailable."""
192 except Exception
as exc:
193 _dbg(
"OC_UI.TERMINAL", func, f
"redaction unavailable: {type(exc).__name__}", 3)
196 return redact_secrets(text)
197 except Exception
as exc:
198 _dbg(
"OC_UI.TERMINAL", func, f
"redaction failed: {type(exc).__name__}", 3)
203 """Parse a tool-call ``arguments`` JSON string into a dict, else ``None``."""
207 parsed = json.loads(arguments)
208 except (json.JSONDecodeError, TypeError, ValueError):
210 return parsed
if isinstance(parsed, dict)
else None
214 """Normalise a path for comparison: forward slashes, no leading ``./``."""
215 text = str(value
or "").replace(
"\\",
"/").strip()
216 while text.startswith(
"./"):
221def _paths_agree(arg_path: object, result_paths: Iterable[object]) -> bool:
222 """True when the recorded call path matches a path the result echoes.
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.
232 candidates = [
_norm_path(p)
for p
in result_paths
if p]
237 a_base = a.rsplit(
"/", 1)[-1]
238 return any(a_base == c.rsplit(
"/", 1)[-1]
for c
in candidates)
242 """Compact byte count: ``29 B`` / ``1.2 KB`` / ``3.4 MB``."""
244 return f
"{n / 1_000_000:.1f} MB"
246 return f
"{n / 1_000:.1f} KB"
251 """Map a raw unified-diff line to a :class:`DiffLine` kind."""
252 if line.startswith(
"@@"):
254 if line.startswith(
"+"):
256 if line.startswith(
"-"):
262 """Shift a ``@@`` hunk header by *offset* so it names REAL file lines."""
263 m = _HUNK_RE.match(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 ''}"
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
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):
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()
290 raw = list(difflib.unified_diff(old_lines, new_lines, n=_CONTEXT_LINES, lineterm=
""))
292 raw = [ln
for ln
in raw
if not (ln.startswith(
"---")
or ln.startswith(
"+++"))]
297 anchor = int(result.get(
"match_line")
or 1)
298 except (TypeError, ValueError):
300 offset = max(0, anchor - 1)
302 body: list[DiffLine] = []
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}"
321 tool=
"edit_file", path=path, lines=lines, added=added, removed=removed,
322 truncated=truncated, hidden=hidden, full_content_hint=path, detail=detail,
327 """Only the NEW side reaches the render lane — show it as additions."""
328 content = args.get(
"content")
329 if not isinstance(content, str):
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)
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"
342 tool=
"write_file", path=path, lines=lines, added=len(body), removed=0,
343 truncated=truncated, hidden=hidden, full_content_hint=path, detail=detail,
349 arguments: str |
None,
352 max_lines: int = DEFAULT_MAX_DIFF_LINES,
353) -> DiffRender |
None:
354 """Build a :class:`DiffRender` for a mutation tool, or ``None`` to degrade.
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).
362 func =
"build_mutation_diff"
363 if tool
not in MUTATION_TOOLS:
365 if not isinstance(result, dict):
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)
374 _dbg(
"OC_UI.TERMINAL", func, f
"{tool}: no usable arguments; no diff", 3)
377 result_paths = (result.get(
"original_path"), result.get(
"actual_path"),
378 result.get(
"written"), result.get(
"edited"), result.get(
"path"))
380 _dbg(
"OC_UI.TERMINAL", func, f
"{tool}: call/result path mismatch; no diff", 3)
383 if tool ==
"edit_file":
390_ELIDED_ARG_FIELDS: tuple[str, ...] = (
"content",
"old_string",
"new_string")
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')))}>"
400 """Compact ``show_tool_call`` argument rendering for a mutation tool.
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.
409 Returns ``None`` for a non-mutation tool or unparseable arguments —
410 the caller then renders the arguments exactly as before.
412 if tool
not in MUTATION_TOOLS:
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)}")
422 parts.append(f
"{key}={json.dumps(value, ensure_ascii=False)}")
423 return ", ".join(parts)
427 """The one-line summary rendered above the diff body."""
428 counts = f
"+{diff.added}"
430 counts += f
" -{diff.removed}"
431 detail = f
" · {diff.detail}" if diff.detail
else ""
432 return f
"{diff.tool} · {diff.path} ({counts}{detail})"
436 """The truncation notice, or ``None`` when the whole diff is shown."""
437 if not diff.truncated:
439 return (f
"… +{diff.hidden} more line(s) not shown — "
440 f
"full content on disk at {diff.full_content_hint}")
455 """Render *diff* as a list of terminal lines (ANSI-styled when *color*).
457 Returns ``[]`` for ``None`` so a caller can branch on emptiness without a
463 def style(key: str, text: str) -> str:
466 return f
"{_ANSI[key]}{text}{_ANSI['reset']}"
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))
477 out.append(line.text)
480 out.append(style(
"dim", footer))
486 "DEFAULT_MAX_DIFF_LINES",
489 "MutationArgsLedger",
490 "build_mutation_diff",
494 "summarize_mutation_call",
None record(self, str tool, str|None arguments)
str|None take(self, str tool)
DiffRender|None build_mutation_diff(str tool, str|None arguments, Any result, *, int max_lines=DEFAULT_MAX_DIFF_LINES)
str diff_header_line(DiffRender diff)
str|None diff_footer_line(DiffRender diff)
list[str] render_diff_plain(DiffRender|None diff, *, bool color=True)
DiffRender|None _build_edit_diff(dict args, dict result, int max_lines)
str _reanchor_hunk(str line, int offset)
str _norm_path(object value)
dict|None _parse_args(str|None arguments)
DiffRender|None _build_write_diff(dict args, dict result, int max_lines)
str|None summarize_mutation_call(str tool, str|None arguments)
tuple[tuple[DiffLine,...], bool, int] _truncate(list[DiffLine] body, int max_lines)
bool _paths_agree(object arg_path, Iterable[object] result_paths)