You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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
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.
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.
As a user, I want unchanged context and hunk headers visually de-emphasised (grey/dim), so that my eye goes to the actual changes.
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.
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.
As a user on a light terminal, I want the diff colours to remain legible, so that red/green/grey are not washed out.
As a user on a dark terminal, I want the diff colours to remain legible, so that the same holds in reverse.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 throughrender-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 presence — details.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.clj — fileChange 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.
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-callwithout: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).
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.
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:approvingmode, a bare🚧 <summary>line above they/n/Yprompt. 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:
:approvingmode, 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.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
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.[truncated]footer, so that a huge edit does not flood the chat viewport.+12 −3), so that I get change magnitude at a glance without expanding.[y/n/Y]prompt clearly separated from the diff, so that I do not lose the approval affordance under a wall of diff lines.render-item-linessignature to stay unchanged, so that existing call sites and tests are untouched by the approval-surface unification.:editshape 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
fileChangedetail:Server side, this is built by the
tool-call-details-before-invocationmultimethod (eca/src/eca/features/tools/filesystem.clj) via the server's owneca.diff/diff— dispatched foredit_file,write_file,preview_file_change(and clojure-mcp file tools). The name is literal: details are produced before invocation, socontent.detailsis populated attoolCallRun(the approval decision point) — not only attoolCalled. Verified:eca/src/eca/features/chat.cljattaches:detailsontoolCallRun,toolCallRunning, andtoolCalled.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
fileChangedetail 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
toolCallRunarm reads a top-levelsubagentDetailskey (chat.clj), whereas the protocol nests everything underdetails: ToolCallDetails. The server'smap->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 realfileChangepayload exactly as eca-cli receives it (drive a liveedit_filethrough the TUI, capture the parsed content map). Pin the exact key path and casing fortype/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
render-item-lines, forced expanded, and shrink the approval renderer to appending only the[y/n/Y]prompt line beneath it.assocs:expanded? trueonto the item before handing it torender-item-lines. No change to therender-item-linessignature — it already branches on(:expanded? item).render-item-linestests still pass unchanged.Pre-work 2 — F02: classify file-changes at ingest, not in the view
chat.cljnormalizes afileChangetool-call detail into an:edit {:path :diff :lines-added :lines-removed}key on the item. Detection is by payload presence —details.type == "fileChange"— not by tool name. This automatically coversedit_file/write_file/apply_patch/future tools, because the server tags them all withfileChange.toolCallRunarm (diff visible at approval) and thetoolCalledarm (diff in the completed block). Use the exact key path pinned by the verify-at-implementation gate above.:tool-callexpanded branch becomes(if (:edit item) (render-diff …) (render-boxes …))— a pure branch on key presence. No payload inspection in the renderer.:editvalue as a malli schema[:map [:path :string] [:diff :string] [:lines-added :int] [:lines-removed :int]]next to the classifier.fileChangepayload through the classifier yields the normalized:editmap; a non-edit tool yields no:editkey.Feature — the diff-colouriser leaf
render-diff [edit-map width] → lineshelper inblocks.clj. Input is the normalized:editmap; output is a vec of ANSI-styled lines.:diffstring 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.ansi-red/ansi-greeninblocks.clj; add a grey/dim for context + headers).[truncated]footer line.width; flows through the existing ANSI/CJK-awarewrap-text, consistent with the rest of the chat.+{lines-added} −{lines-removed}from the:editmap (user story 12).:tool-callexpanded arm delegates torender-diffwhen(:edit item)is present, else to the existing box renderer.Modules touched
src/eca_cli/view/blocks.clj— newrender-diffcolouriser leaf;:tool-callarm gains the(if (:edit item) …)branch and the collapsed+N −Mbadge.src/eca_cli/view.clj— approval renderer shrinks to mountrender-item-lines(force-expanded) +[y/n/Y]line.src/eca_cli/chat.clj—fileChangeclassifier +:editenrichment in thetoolCallRunandtoolCalledarms;:editmalli schema.Respected constraints
wrap-text,:chat-line-spansmap,upsert-tool-callinteractive-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-diffleaf (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-linesintegration (blocks_test.clj): an expanded:tool-callitem carrying an:editmap renders a diff (delegates torender-diff); an expanded:tool-callwithout:editstill renders the existing arguments/output boxes (fallback intact); the collapsed one-liner shows+N −M. Prior art:render-item-lines-rich-test.fileChangepayload through the classifier yields the normalized:edit {:path :diff :lines-added :lines-removed}; a non-fileChangetool call yields no:editkey. Tested at ingest, no renderer. Prior art:chat_test.clj.: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
fileChangepayload 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
urlcontent type and dropped-content-type logging (Phase 8, F05).Further Notes
docs/refactor/04-render-seam-healthcheck.md(F01 Strong, F02 partner);docs/roadmap.mdPhase 6 (goal + stopping criteria); reconciled numbering frozen in issue Reconcile decision: fold gap inventory into roadmap phases 6-11+ #24; upstream payload shape verified againsteca/docs/protocol.md+eca/src/eca/features/tools/filesystem.clj+eca/src/eca/features/chat.clj.render-item-lineson:sub-itemsatwidth-2— file edits inside a spawned-agent step get the diff for free (user story 13), no extra work.y/n/Yprompt.[truncated]footer.