Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions bench/baseline/word-diff-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"schemaVersion": 1,
"createdAt": "2026-06-15T10:37:45.080Z",
"gitCommit": "4004d594227e25558990d5e720b8fcba35fc3c29",
"iterations": 30,
"durationMs": 1153.193667,
"results": [
{
"suite": "word-diff",
"package": "@code-yeongyu/senpi",
"fixture": "5-single-line-diff-cases",
"iterations": 30,
"samples": [
26.71362499999998,
26.953125,
25.40891700000003,
26.359959000000003,
24.547124999999937,
25.315541999999937,
22.533000000000015,
22.749584000000027,
22.48199999999997,
22.357333999999923,
22.30574999999999,
22.35583299999996,
22.230583000000024,
22.32379100000003,
22.413125000000036,
22.256041000000096,
22.238624999999956,
22.34375,
22.092667000000006,
22.41504199999997,
21.774666000000025,
22.112041999999974,
22.203499999999963,
22.27870900000005,
22.003000000000043,
22.05716700000005,
22.376249999999914,
22.076624999999922,
22.72583399999985,
22.159374999999955
],
"medianMs": 22.34375,
"p95Ms": 26.71362499999998,
"heapDeltaBytes": -130976,
"rssDeltaBytes": 655360,
"fastPathEligibleCases": [
"identical",
"single-word-replacement",
"long-single-span-replacement"
],
"fallbackCases": [
"whitespace-only",
"multi-span"
],
"metadata": {
"createdAt": "2026-06-15T10:37:45.073Z",
"gitCommit": "4004d594227e25558990d5e720b8fcba35fc3c29",
"nodeVersion": "v26.3.0",
"platform": "darwin",
"arch": "arm64",
"cpu": "Apple M4 Pro"
}
}
]
}
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Changed

- Improved single-line intra-line diff rendering with conservative fast paths for identical, long, and single-span edits.

### Removed

## [2026.6.15] - 2026-06-15
Expand Down
94 changes: 94 additions & 0 deletions packages/coding-agent/bench/word-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { renderDiff } from "../src/modes/interactive/components/diff.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { forceGc, metadata, percentile, readIterations } from "../../tui/bench/_meta.ts";

const REPEATS_PER_SAMPLE = 600;
const LONG_LINE_PREFIX = Array.from({ length: 120 }, (_, index) => `token${index}`).join(" ");

type WordDiffCase = {
readonly name: string;
readonly removed: string;
readonly added: string;
readonly fastPathEligible: boolean;
};

const CASES: readonly WordDiffCase[] = [
{
name: "identical",
removed: "const unchanged = formatValue(input);",
added: "const unchanged = formatValue(input);",
fastPathEligible: true,
},
{
name: "single-word-replacement",
removed: " return format(oldValue);",
added: " return format(newValue);",
fastPathEligible: true,
},
{
name: "long-single-span-replacement",
removed: `${LONG_LINE_PREFIX} before tail`,
added: `${LONG_LINE_PREFIX} after tail`,
fastPathEligible: true,
},
{
name: "whitespace-only",
removed: "const result = format(value);",
added: "const result = format(value);",
fastPathEligible: false,
},
{
name: "multi-span",
removed: "alpha beta gamma delta",
added: "alpha theta gamma omega",
fastPathEligible: false,
},
] as const;

function diffText(testCase: WordDiffCase): string {
return [`-1 ${testCase.removed}`, `+1 ${testCase.added}`].join("\n");
}

function runScenario(): number {
let renderedLength = 0;
for (let repeat = 0; repeat < REPEATS_PER_SAMPLE; repeat++) {
for (const testCase of CASES) {
renderedLength += renderDiff(diffText(testCase)).length;
}
}
return renderedLength;
}

function timeScenario(): number {
const start = performance.now();
const renderedLength = runScenario();
if (renderedLength === 0) throw new Error("Expected rendered diff output");
return performance.now() - start;
}

initTheme("dark");
const iterations = readIterations(20);
for (let i = 0; i < Math.min(3, iterations); i++) runScenario();
forceGc();
const before = process.memoryUsage();
const samples: number[] = [];
for (let i = 0; i < iterations; i++) samples.push(timeScenario());
forceGc();
const after = process.memoryUsage();

console.log(
JSON.stringify({
suite: "word-diff",
package: "@code-yeongyu/senpi",
fixture: `${CASES.length}-single-line-diff-cases`,
iterations,
samples,
medianMs: percentile(samples, 50),
p95Ms: percentile(samples, 95),
heapDeltaBytes: after.heapUsed - before.heapUsed,
rssDeltaBytes: after.rss - before.rss,
fastPathEligibleCases: CASES.filter((testCase) => testCase.fastPathEligible).map((testCase) => testCase.name),
fallbackCases: CASES.filter((testCase) => !testCase.fastPathEligible).map((testCase) => testCase.name),
metadata: metadata(),
}),
);
94 changes: 92 additions & 2 deletions packages/coding-agent/src/modes/interactive/components/diff.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import type { Change } from "diff";
import * as Diff from "diff";
import { theme } from "../theme/theme.ts";

export const LONG_LINE_FAST_PATH_LIMIT = 500;

type IntraLineDiff = {
readonly removedLine: string;
readonly addedLine: string;
};

type ReplacementSpan = {
readonly prefix: string;
readonly removed: string;
readonly added: string;
readonly suffix: string;
};

/**
* Parse diff line to extract prefix, line number, and content.
* Format: "+123 content" or "-123 content" or " 123 content" or " ..."
Expand All @@ -23,8 +38,8 @@ function replaceTabs(text: string): string {
* Uses diffWords which groups whitespace with adjacent words for cleaner highlighting.
* Strips leading whitespace from inverse to avoid highlighting indentation.
*/
function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
const wordDiff = Diff.diffWords(oldContent, newContent);
export function renderIntraLineDiffWithDiffWords(oldContent: string, newContent: string): IntraLineDiff {
const wordDiff: readonly Change[] = Diff.diffWords(oldContent, newContent);

let removedLine = "";
let addedLine = "";
Expand Down Expand Up @@ -65,6 +80,81 @@ function renderIntraLineDiff(oldContent: string, newContent: string): { removedL
return { removedLine, addedLine };
}

export function renderIntraLineDiff(oldContent: string, newContent: string): IntraLineDiff {
return (
renderIntraLineDiffFastPath(oldContent, newContent) ?? renderIntraLineDiffWithDiffWords(oldContent, newContent)
);
}

export function renderIntraLineDiffFastPath(oldContent: string, newContent: string): IntraLineDiff | null {
if (oldContent === newContent) return { removedLine: oldContent, addedLine: newContent };
return renderSingleSpanIntraLineDiff(oldContent, newContent);
}

function renderSingleSpanIntraLineDiff(oldContent: string, newContent: string): IntraLineDiff | null {
const span = findSingleDiffWordsReplacement(oldContent, newContent);
if (!span) return null;
return {
removedLine: `${span.prefix}${theme.inverse(span.removed)}${span.suffix}`,
addedLine: `${span.prefix}${theme.inverse(span.added)}${span.suffix}`,
};
}

function findSingleDiffWordsReplacement(oldContent: string, newContent: string): ReplacementSpan | null {
let start = 0;
while (start < oldContent.length && start < newContent.length && oldContent[start] === newContent[start]) {
start++;
}

let oldEnd = oldContent.length;
let newEnd = newContent.length;
while (oldEnd > start && newEnd > start && oldContent[oldEnd - 1] === newContent[newEnd - 1]) {
oldEnd--;
newEnd--;
}

while (
start > 0 &&
(isAsciiWordCode(oldContent.charCodeAt(start - 1)) || isAsciiWordCode(newContent.charCodeAt(start - 1)))
) {
start--;
}
while (
oldEnd < oldContent.length &&
newEnd < newContent.length &&
(isAsciiWordCode(oldContent.charCodeAt(oldEnd)) || isAsciiWordCode(newContent.charCodeAt(newEnd)))
) {
oldEnd++;
newEnd++;
}

const prefix = oldContent.slice(0, start);
const removed = oldContent.slice(start, oldEnd);
const added = newContent.slice(start, newEnd);
const oldSuffix = oldContent.slice(oldEnd);
const newSuffix = newContent.slice(newEnd);

if (oldSuffix !== newSuffix) return null;
if (!isSingleDiffWordsReplacement(removed, added)) return null;
return { prefix, removed, added, suffix: oldSuffix };
}

function isSingleDiffWordsReplacement(removed: string, added: string): boolean {
return removed.length > 0 && added.length > 0 && isSimpleDiffToken(removed) && isSimpleDiffToken(added);
}

function isSimpleDiffToken(value: string): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (!isAsciiWordCode(code)) return false;
}
return true;
}

function isAsciiWordCode(code: number): boolean {
return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || code === 95 || (code >= 97 && code <= 122);
}

export interface RenderDiffOptions {
/** File path (unused, kept for API compatibility) */
filePath?: string;
Expand Down
43 changes: 43 additions & 0 deletions packages/coding-agent/test/diff-intraline-fastpath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
LONG_LINE_FAST_PATH_LIMIT,
renderIntraLineDiffFastPath,
renderIntraLineDiffWithDiffWords,
} from "../src/modes/interactive/components/diff.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";

describe("renderIntraLineDiff fast path", () => {
beforeAll(() => {
initTheme("dark");
});

it("returns byte-identical output to diffWords for provable single-span replacements", () => {
const longPrefix = Array.from({ length: 90 }, (_, index) => `token${index}`).join(" ");
const cases: ReadonlyArray<readonly [oldContent: string, newContent: string]> = [
["const status = oldValue;", "const status = newValue;"],
[" return format(oldValue);", " return format(newValue);"],
["alpha beta gamma", "alpha delta gamma"],
["alphaBetaGamma", "alphaThetaGamma"],
[`${longPrefix} before tail`, `${longPrefix} after tail`],
];
expect(longPrefix.length).toBeGreaterThan(LONG_LINE_FAST_PATH_LIMIT);

for (const [oldContent, newContent] of cases) {
const fastPath = renderIntraLineDiffFastPath(oldContent, newContent);

expect(fastPath).toEqual(renderIntraLineDiffWithDiffWords(oldContent, newContent));
}
});

it("falls back for whitespace-only and multi-span changes", () => {
const cases: ReadonlyArray<readonly [oldContent: string, newContent: string]> = [
["const x = 1;", "const x = 1;"],
["alpha beta gamma delta", "alpha theta gamma omega"],
[" a-", " --"],
];

for (const [oldContent, newContent] of cases) {
expect(renderIntraLineDiffFastPath(oldContent, newContent)).toBeNull();
}
});
});
53 changes: 53 additions & 0 deletions packages/coding-agent/test/diff-intraline-no-diffwords.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Change } from "diff";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
LONG_LINE_FAST_PATH_LIMIT,
renderIntraLineDiff,
renderIntraLineDiffFastPath,
} from "../src/modes/interactive/components/diff.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";

type DiffWords = (oldStr: string, newStr: string) => Change[];

const diffWordsMock = vi.hoisted(() =>
vi.fn<DiffWords>(() => {
throw new Error("diffWords should not be called");
}),
);

vi.mock("diff", () => ({
diffWords: diffWordsMock,
}));

describe("renderIntraLineDiff no-diffWords fast path", () => {
beforeAll(() => {
initTheme("dark");
});

beforeEach(() => {
diffWordsMock.mockClear();
});

it("skips diffWords when the line content is identical", () => {
const content = "const unchanged = formatValue(input);";

const result = renderIntraLineDiff(content, content);

expect(result).toEqual({ removedLine: content, addedLine: content });
expect(diffWordsMock).not.toHaveBeenCalled();
});

it("skips diffWords when a very long line has one replacement span", () => {
const prefix = Array.from({ length: 90 }, (_, index) => `token${index}`).join(" ");
expect(prefix.length).toBeGreaterThan(LONG_LINE_FAST_PATH_LIMIT);
const removed = `${prefix} before tail`;
const added = `${prefix} after tail`;
const fastPath = renderIntraLineDiffFastPath(removed, added);

const result = renderIntraLineDiff(removed, added);

expect(fastPath).not.toBeNull();
expect(result).toEqual(fastPath);
expect(diffWordsMock).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions scripts/run-pr530-benchmarks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const BENCHES = {
"coding-agent-bash-output": "packages/coding-agent/bench/bash-output.ts",
"coding-agent-jsonl-parse": "packages/coding-agent/bench/jsonl-parse.ts",
"coding-agent-rpc-event-emit": "packages/coding-agent/bench/rpc-event-emit.ts",
"word-diff": "packages/coding-agent/bench/word-diff.ts",
};

function parseArgs(argv) {
Expand Down
Loading