From 631021aa67180c8120df7a4b1e7e8b05495b372f Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 4 Mar 2026 22:37:26 +0100 Subject: [PATCH] feat(patch): added compact hashline diff preview for patch responses - Added buildCompactHashlineDiffPreview() function to generate collapsed diff previews for tool responses. - Enhanced EditTool response to include diff summary with added/removed line counts and compact preview. - Added project-level discovery for .agent/ and .agents/ directories walking up to repository root. - Implemented helper functions for truncating long diff runs (collapseFromStart, collapseFromEnd, collapseFromMiddle). --- packages/coding-agent/CHANGELOG.md | 6 +- packages/coding-agent/src/patch/hashline.ts | 113 ++++++++++++++++++ packages/coding-agent/src/patch/index.ts | 15 ++- .../coding-agent/test/core/hashline.test.ts | 43 +++++++ 4 files changed, 174 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d9a4516e0..8c7d0837e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,11 +1,15 @@ # Changelog ## [Unreleased] - ### Added +- Added `buildCompactHashlineDiffPreview()` function to generate compact diff previews for model-visible tool responses, collapsing long unchanged runs and consecutive additions/removals to show edit shape without full file content - Added project-level discovery for `.agent/` and `.agents/` directories, walking up from cwd to repo root (matching behavior of other providers like `.omp`, `.claude`, `.codex`). Applies to skills, rules, prompts, commands, context files (AGENTS.md), and system prompts (SYSTEM.md) +### Changed + +- Changed edit tool response to include diff summary with line counts (+added -removed) and a compact diff preview instead of warnings-only output + ### Fixed - Fixed `:thinking` suffix in `modelRoles` config values silently breaking model resolution (e.g., `slow: anthropic/claude-opus-4-6:high`) and being stripped on Ctrl+P role cycling diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index 79635b805..f8718257d 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -710,3 +710,116 @@ export function applyHashlineEdits( } } } + +export interface CompactHashlineDiffPreview { + preview: string; + addedLines: number; + removedLines: number; +} + +export interface CompactHashlineDiffOptions { + maxUnchangedRun?: number; + maxAdditionRun?: number; + maxDeletionRun?: number; + maxOutputLines?: number; +} + +const NUMBERED_DIFF_LINE_RE = /^([ +-])(\d+)\|(.*)$/; + +type DiffRunKind = " " | "+" | "-" | "meta"; +type DiffRun = { kind: DiffRunKind; lines: string[] }; + +function collapseFromStart(lines: string[], maxLines: number, label: string): string[] { + if (lines.length <= maxLines) return lines; + const hidden = lines.length - maxLines; + return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`]; +} + +function collapseFromEnd(lines: string[], maxLines: number, label: string): string[] { + if (lines.length <= maxLines) return lines; + const hidden = lines.length - maxLines; + return [` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)]; +} + +function collapseFromMiddle(lines: string[], maxLines: number, label: string): string[] { + if (lines.length <= maxLines * 2) return lines; + const hidden = lines.length - maxLines * 2; + return [...lines.slice(0, maxLines), ` ... ${hidden} more ${label} lines`, ...lines.slice(-maxLines)]; +} + +function splitDiffRuns(lines: string[]): DiffRun[] { + const runs: DiffRun[] = []; + for (const line of lines) { + const match = NUMBERED_DIFF_LINE_RE.exec(line); + const kind = (match?.[1] as " " | "+" | "-" | undefined) ?? "meta"; + const prev = runs[runs.length - 1]; + if (prev && prev.kind === kind) { + prev.lines.push(line); + continue; + } + runs.push({ kind, lines: [line] }); + } + return runs; +} + +/** + * Build a compact diff preview suitable for model-visible tool responses. + * + * Collapses long unchanged runs and long consecutive additions/removals so the + * model sees the shape of edits without replaying full file content. + */ +export function buildCompactHashlineDiffPreview( + diff: string, + options: CompactHashlineDiffOptions = {}, +): CompactHashlineDiffPreview { + const maxUnchangedRun = options.maxUnchangedRun ?? 2; + const maxAdditionRun = options.maxAdditionRun ?? 2; + const maxDeletionRun = options.maxDeletionRun ?? 2; + const maxOutputLines = options.maxOutputLines ?? 16; + + const inputLines = diff.length === 0 ? [] : diff.split("\n"); + const runs = splitDiffRuns(inputLines); + + const out: string[] = []; + let addedLines = 0; + let removedLines = 0; + + for (let runIndex = 0; runIndex < runs.length; runIndex++) { + const run = runs[runIndex]; + switch (run.kind) { + case "meta": + out.push(...run.lines); + break; + case "+": + addedLines += run.lines.length; + out.push(...collapseFromStart(run.lines, maxAdditionRun, "added")); + break; + case "-": + removedLines += run.lines.length; + out.push(...collapseFromStart(run.lines, maxDeletionRun, "removed")); + break; + case " ": + if (runIndex === 0) { + out.push(...collapseFromEnd(run.lines, maxUnchangedRun, "unchanged")); + break; + } + if (runIndex === runs.length - 1) { + out.push(...collapseFromStart(run.lines, maxUnchangedRun, "unchanged")); + break; + } + out.push(...collapseFromMiddle(run.lines, maxUnchangedRun, "unchanged")); + break; + } + } + + if (out.length > maxOutputLines) { + const hidden = out.length - maxOutputLines; + return { + preview: [...out.slice(0, maxOutputLines), ` ... ${hidden} more preview lines`].join("\n"), + addedLines, + removedLines, + }; + } + + return { preview: out.join("\n"), addedLines, removedLines }; +} diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index fa66a3ad9..208af86e5 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard" import { applyPatch } from "./applicator"; import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff"; import { findMatch } from "./fuzzy"; -import { type Anchor, applyHashlineEdits, computeLineHash, type HashlineEdit, parseTag } from "./hashline"; +import { + type Anchor, + applyHashlineEdits, + buildCompactHashlineDiffPreview, + computeLineHash, + type HashlineEdit, + parseTag, +} from "./hashline"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; // Internal imports @@ -597,11 +604,15 @@ export class EditTool implements AgentTool { .get(); const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`; + const preview = buildCompactHashlineDiffPreview(diffResult.diff); + const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`; + const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; + const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : ""; return { content: [ { type: "text", - text: `${resultText}${result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""}`, + text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`, }, ], details: { diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index f699e3149..4e9336ed5 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { applyHashlineEdits, + buildCompactHashlineDiffPreview, computeLineHash, formatHashLines, HashlineMismatchError, @@ -806,6 +807,48 @@ describe("applyHashlineEdits — errors", () => { }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// buildCompactHashlineDiffPreview +// ═══════════════════════════════════════════════════════════════════════════ + +describe("buildCompactHashlineDiffPreview", () => { + it("keeps trailing context for first unchanged run", () => { + const diff = [" 1|ctx-a", " 2|ctx-b", " 3|ctx-c", " 4|ctx-d", "+5|added"].join("\n"); + + const preview = buildCompactHashlineDiffPreview(diff); + + expect(preview.preview).not.toContain(" 1|ctx-a"); + expect(preview.preview).not.toContain(" 2|ctx-b"); + expect(preview.preview).toContain(" 3|ctx-c"); + expect(preview.preview).toContain(" 4|ctx-d"); + expect(preview.preview).toContain(" ... 2 more unchanged lines"); + expect(preview.preview).toContain("+5|added"); + }); + + it("collapses long addition runs and tracks line counts", () => { + const diff = [" 1|head", "+2|one", "+3|two", "+4|three", "+5|four", "-2|old"].join("\n"); + + const preview = buildCompactHashlineDiffPreview(diff); + + expect(preview.preview).toContain("+2|one"); + expect(preview.preview).toContain("+3|two"); + expect(preview.preview).toContain(" ... 2 more added lines"); + expect(preview.addedLines).toBe(4); + expect(preview.removedLines).toBe(1); + }); + it("keeps leading context for last unchanged run", () => { + const diff = ["-10|old", "+10|new", " 11|ctx-a", " 12|ctx-b", " 13|ctx-c", " 14|ctx-d"].join("\n"); + + const preview = buildCompactHashlineDiffPreview(diff); + + expect(preview.preview).toContain(" 11|ctx-a"); + expect(preview.preview).toContain(" 12|ctx-b"); + expect(preview.preview).not.toContain(" 13|ctx-c"); + expect(preview.preview).not.toContain(" 14|ctx-d"); + expect(preview.preview).toContain(" ... 2 more unchanged lines"); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // stripNewLinePrefixes — regression tests for DIFF_PLUS_RE // ═══════════════════════════════════════════════════════════════════════════