Skip to content

Phase 6: Tool-Call Diff Display #25

Description

@ssjoleary

Problem Statement

When the LLM asks to modify a file, an eca-cli user sees only a one-line tool-call summary (e.g. edit_file foo.clj) and, in :approving mode, a bare 🚧 <summary> line above the y/n/Y prompt. They cannot see what the tool is about to change to a file — added lines, removed lines, surrounding context — before deciding to approve it, nor after it completes. Approving a blind edit is the single highest-visibility gap in the standalone-TUI-client experience: every other terminal coding assistant shows a coloured diff at the approval moment, and eca-cli does not.

Solution

When a tool call modifies a file, the expanded tool-call block renders a coloured unified diff: ANSI red for removed lines, green for added lines, grey for unchanged context, dim for hunk headers. The diff is visible in two places, driven by one renderer:

  1. Before approval — in :approving mode, the focused tool-call block renders fully expanded with the diff, immediately above the [y/n/Y] prompt, so the approval decision is informed by the actual change.
  2. In the completed block — after toolCalled, expanding the tool-call shows the same diff.

The diff is not computed by eca-cli. The ECA server computes it and ships it in the tool-call payload (see Implementation Decisions). eca-cli's job is to detect the file-change payload at ingest and colourise the server's diff string at render. Non-edit tool calls fall back unchanged to the existing arguments/output box renderer. Diff colours read on both light and dark terminals. Diffs over 500 rendered lines truncate with a [truncated] footer.

User Stories

  1. As a standalone-TUI-client user, I want to see a unified diff of a file edit before I approve it, so that I can judge the change instead of approving blind.
  2. As a user, I want removed lines shown in red and added lines in green, so that the direction of each change is obvious at a glance.
  3. As a user, I want unchanged context and hunk headers visually de-emphasised (grey/dim), so that my eye goes to the actual changes.
  4. As a user, I want the same diff to appear in the completed tool-call block after the edit runs, so that I can review what was done when scrolling back.
  5. As a user, I want the diff to render identically in the approval prompt and the completed block, so that there is no discrepancy between what I approved and what I see afterwards.
  6. As a user on a light terminal, I want the diff colours to remain legible, so that red/green/grey are not washed out.
  7. As a user on a dark terminal, I want the diff colours to remain legible, so that the same holds in reverse.
  8. As a user, I want file-edit tool calls (edit_file, write_file, apply_patch, and any future edit tool) recognised, so that all of them get a diff — without eca-cli hard-coding tool names.
  9. As a user, I want a tool call that carries no file-change payload to fall back to the existing arguments/output boxes, so that non-edit tools are unaffected.
  10. As a user, I want a very large diff (>500 rendered lines) truncated with a [truncated] footer, so that a huge edit does not flood the chat viewport.
  11. As a user, I want the diff to respect the current terminal width, so that long lines wrap or clip consistently with the rest of the chat.
  12. As a user, I want the collapsed tool-call one-liner to show an added/removed line count (+12 −3), so that I get change magnitude at a glance without expanding.
  13. As a user reviewing a sub-agent's steps, I want file edits nested inside a spawned-agent tool call to render their diffs too, so that sub-agent edits are as visible as top-level ones.
  14. As a user, I want the collapsed tool-call line to stay a compact one-liner, so that the diff only appears once I (or approval mode) expand the block.
  15. As a user, I want the [y/n/Y] prompt clearly separated from the diff, so that I do not lose the approval affordance under a wall of diff lines.
  16. As a maintainer, I want the diff renderer written once and mounted in both the block and the approval surface, so that the two can never drift out of sync.
  17. As a maintainer, I want file-change detection to happen at ingest (not in the view), so that the renderer stays a pure function of the item and never inspects tool payloads.
  18. As a maintainer, I want the render-item-lines signature to stay unchanged, so that existing call sites and tests are untouched by the approval-surface unification.
  19. As a maintainer, I want the :edit shape documented as a malli-described variant of the tool-call item, so that the contract for the diff renderer is explicit.

Implementation Decisions

Sequenced as one cohesive ticket. F01 and F02 are pre-work, done and REPL-verified green first; the diff-colouriser leaf stacks on top. Rationale (render-seam health-check docs/refactor/04-render-seam-healthcheck.md): both refactors are motivated only by Phase 6; a floating refactor ticket would fragment a naturally-cohesive change.

Upstream fact — the server computes the diff (verified against eca server source)

ECA does not hand eca-cli raw before/after and expect the client to diff. The server tool layer computes the diff itself and attaches it to the tool-call content as a fileChange detail:

// protocol.md — ToolCallDetails union, FileChangeDetails arm
interface FileChangeDetails {
    type: 'fileChange';
    path: string;
    diff: string;         // "The content diff of this file change"
    linesAdded: number;
    linesRemoved: number;
}

Server side, this is built by the tool-call-details-before-invocation multimethod (eca/src/eca/features/tools/filesystem.clj) via the server's own eca.diff/diff — dispatched for edit_file, write_file, preview_file_change (and clojure-mcp file tools). The name is literal: details are produced before invocation, so content.details is populated at toolCallRun (the approval decision point) — not only at toolCalled. Verified: eca/src/eca/features/chat.clj attaches :details on toolCallRun, toolCallRunning, and toolCalled.

Consequence — colourise, do not compute. eca-cli must not extract before/after, must not run an LCS, must not match tool names to reconstruct the edit. It reads the server's fileChange detail and colours the pre-computed unified-diff string. This is simpler and correct — the colours sit on exactly the diff the server applied.

⚠ Verify-at-implementation gate (do this first, before F02)

eca-cli's current toolCallRun arm reads a top-level subagentDetails key (chat.clj), whereas the protocol nests everything under details: ToolCallDetails. The server's map->camel-cased-map (eca/src/eca/shared.clj) camelCases top-level keys only — nested detail keys may not arrive in the shape the protocol doc implies. Therefore, before wiring F02, log one real fileChange payload exactly as eca-cli receives it (drive a live edit_file through the TUI, capture the parsed content map). Pin the exact key path and casing for type / diff / path / linesAdded / linesRemoved. The classifier reads whatever that live payload actually contains — not what this spec assumes. This is a perceptual-gap guard: the shape must be observed, not inferred.

Pre-work 1 — F01: unify the approval surface onto the block renderer

  • The approval surface currently hand-builds its own string and bypasses the block renderer entirely, rendering the same tool-call concept a second way.
  • Change: render the focused tool-call through render-item-lines, forced expanded, and shrink the approval renderer to appending only the [y/n/Y] prompt line beneath it.
  • Force-expand mechanism: the caller assocs :expanded? true onto the item before handing it to render-item-lines. No change to the render-item-lines signature — it already branches on (:expanded? item).
  • Net effect: one block renderer, two mount points (expanded block + approval prompt). The diff colouriser is written once and appears in both for free, including the light/dark colour choice.
  • Verify green at the REPL before proceeding: approval prompt renders the full tool-call block followed by the prompt line; existing render-item-lines tests still pass unchanged.

Pre-work 2 — F02: classify file-changes at ingest, not in the view

  • Change: a single classifier fn in chat.clj normalizes a fileChange tool-call detail into an :edit {:path :diff :lines-added :lines-removed} key on the item. Detection is by payload presencedetails.type == "fileChange"not by tool name. This automatically covers edit_file/write_file/apply_patch/future tools, because the server tags them all with fileChange.
  • Where: called in both the toolCallRun arm (diff visible at approval) and the toolCalled arm (diff in the completed block). Use the exact key path pinned by the verify-at-implementation gate above.
  • View arm: the :tool-call expanded branch becomes (if (:edit item) (render-diff …) (render-boxes …)) — a pure branch on key presence. No payload inspection in the renderer.
  • Schema: document the enriched item's :edit value as a malli schema [:map [:path :string] [:diff :string] [:lines-added :int] [:lines-removed :int]] next to the classifier.
  • Verify green at the REPL before proceeding: feeding a captured fileChange payload through the classifier yields the normalized :edit map; a non-edit tool yields no :edit key.

Feature — the diff-colouriser leaf

  • A pure render-diff [edit-map width] → lines helper in blocks.clj. Input is the normalized :edit map; output is a vec of ANSI-styled lines.
  • Colourise the server's :diff string by leading character of each line: - → red, + → green, @@ hunk header → dim, everything else (context) → grey. No hunk computation, no context-count config — the server already chose the context.
  • Colours read on both light and dark terminals (align with existing ansi-red/ansi-green in blocks.clj; add a grey/dim for context + headers).
  • >500-line truncation: diffs longer than 500 rendered lines are clipped with a [truncated] footer line.
  • Respects width; flows through the existing ANSI/CJK-aware wrap-text, consistent with the rest of the chat.
  • The collapsed one-liner shows +{lines-added} −{lines-removed} from the :edit map (user story 12).
  • The :tool-call expanded arm delegates to render-diff when (:edit item) is present, else to the existing box renderer.

Modules touched

  • src/eca_cli/view/blocks.clj — new render-diff colouriser leaf; :tool-call arm gains the (if (:edit item) …) branch and the collapsed +N −M badge.
  • src/eca_cli/view.clj — approval renderer shrinks to mount render-item-lines (force-expanded) + [y/n/Y] line.
  • src/eca_cli/chat.cljfileChange classifier + :edit enrichment in the toolCallRun and toolCalled arms; :edit malli schema.

Respected constraints

  • ADR-0001 chat/session split untouched — this is render/ingest, not conversation identity.
  • Standalone-TUI-client vs editor-client distinction (CONTEXT.md) untouched — the diff comes from the ECA protocol payload, not from a host buffer.
  • Green-bill items not touched: Phase-B view split, wrap-text, :chat-line-spans map, upsert-tool-call interactive-state protection, sub-agent recursion (diffs inside sub-agent steps render for free).

Testing Decisions

Good tests assert external rendered behaviour — the vec of lines a pure fn returns for a given item at a given width — never internal helper structure. The whole surface stays plain (= expected (render input w)) with no mocking, because the core is genuinely pure.

  • render-diff leaf (blocks_test.clj): direct tests of the colouriser — a - line carries the red code, + carries green, @@ carries dim, context carries grey; the >500-line [truncated] footer appears; empty/whitespace diff handled. This is the one non-trivial rendering piece, so it earns direct leaf tests. (Input is a fixed unified-diff string — no server needed.)
  • render-item-lines integration (blocks_test.clj): an expanded :tool-call item carrying an :edit map renders a diff (delegates to render-diff); an expanded :tool-call without :edit still renders the existing arguments/output boxes (fallback intact); the collapsed one-liner shows +N −M. Prior art: render-item-lines-rich-test.
  • Classification (chat_test.clj): feeding a captured fileChange payload through the classifier yields the normalized :edit {:path :diff :lines-added :lines-removed}; a non-fileChange tool call yields no :edit key. Tested at ingest, no renderer. Prior art: chat_test.clj.
  • Approval-surface unification (view_test.clj): the approval render contains the full tool-call block (including a diff when the pending tool carries :edit) followed by the [y/n/Y] line. Prior art: view_test.clj.

Every leaf/ingest test is REPL-driven: eval the fn, call with a sample payload, verify output, before and after each edit. The fileChange payload fixture used across classifier tests must be a captured real payload from the verify-at-implementation gate, not a hand-guessed shape.

Out of Scope

  • Computing diffs client-side — the server supplies them. No LCS, no diff library, no before/after extraction.
  • Markdown rendering of assistant/tool text (Phase 8).
  • Wide-table horizontal scroll / horizontal-scroll axis (Phase 8, F04) — the diff uses the existing vertical line-slice pipeline only.
  • url content type and dropped-content-type logging (Phase 8, F05).
  • Syntax highlighting within diff lines — only add/remove/context/header colouring is in scope.
  • Word-level / intra-line diff colouring — line-level only (whatever granularity the server's diff string carries).
  • Inline image rendering (cut — editor/GUI-bound per issue Reconcile decision: fold gap inventory into roadmap phases 6-11+ #24 scope calls).
  • Any change to how edits are applied — display only; ECA server performs the edit.

Further Notes

  • Source: render-seam health-check docs/refactor/04-render-seam-healthcheck.md (F01 Strong, F02 partner); docs/roadmap.md Phase 6 (goal + stopping criteria); reconciled numbering frozen in issue Reconcile decision: fold gap inventory into roadmap phases 6-11+ #24; upstream payload shape verified against eca/docs/protocol.md + eca/src/eca/features/tools/filesystem.clj + eca/src/eca/features/chat.clj.
  • The health-check's top recommendation: do F01 before Phase 6 (only Strong finding, passes the deletion test), F02 as its partner. Doing them first makes Phase 6 almost purely the colouriser leaf, both display sites already wired.
  • Sub-agent nesting already recurses through render-item-lines on :sub-items at width-2 — file edits inside a spawned-agent step get the diff for free (user story 13), no extra work.
  • Stopping criteria (roadmap Phase 6), all must hold:
    • File-edit tool calls render as a unified diff in the expanded block.
    • Diff colours work in light and dark terminals.
    • Approval mode shows the diff before the y/n/Y prompt.
    • Non-edit tool calls fall back to the existing output renderer.
    • Large diffs (>500 lines) truncate with a [truncated] footer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions