Skip to content

Commit 12e933b

Browse files
authored
feat: next edit char-level diff (#7776)
* remove filter from next edit svg border * reduce border radius to 2 in next edit svg * update border * remove strikethrough from next edit deletion ui * remove diff background for now * feat: word-level diff in next edit svg * fix: don't alternate non-responses due to empty chain * fix: log next edit completions to continue console * fix: test and update chaining behavior to avoid repetitive requests and avoid alternating non-responses
1 parent d78fc99 commit 12e933b

File tree

5 files changed

+608
-59
lines changed

5 files changed

+608
-59
lines changed

core/codeRenderer/CodeRenderer.ts

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
getSingletonHighlighter,
2020
Highlighter,
2121
} from "shiki";
22-
import { DiffLine } from "..";
22+
import { DiffChar, DiffLine } from "..";
2323
import { escapeForSVG, kebabOfThemeStr } from "../util/text";
2424

2525
interface CodeRendererOptions {
@@ -228,6 +228,7 @@ export class CodeRenderer {
228228
options: ConversionOptions,
229229
currLineOffsetFromTop: number,
230230
newDiffLines: DiffLine[],
231+
newDiffChars: DiffChar[],
231232
): Promise<Buffer> {
232233
const strokeWidth = 1;
233234
const highlightedCodeHtml = await this.highlightCode(
@@ -236,13 +237,14 @@ export class CodeRenderer {
236237
currLineOffsetFromTop,
237238
newDiffLines,
238239
);
239-
// console.log(highlightedCodeHtml);
240240

241241
const { guts, lineBackgrounds } = this.convertShikiHtmlToSvgGut(
242242
highlightedCodeHtml,
243243
options,
244+
newDiffChars,
244245
);
245246
const backgroundColor = this.getBackgroundColor(highlightedCodeHtml);
247+
const borderColor = "#6b6b6b";
246248

247249
const lines = code.split("\n");
248250
const actualHeight = lines.length * options.lineHeight;
@@ -256,24 +258,68 @@ export class CodeRenderer {
256258
}
257259
</style>
258260
<g>
259-
<rect x="0" y="0" rx="10" ry="10" width="${options.dimensions.width}" height="${actualHeight}" fill="${this.editorBackground}" shape-rendering="crispEdges" />
261+
<rect x="0" y="0" rx="2" ry="2" width="${options.dimensions.width}" height="${actualHeight}" fill="${backgroundColor}" stroke="${borderColor}" stroke-width="${strokeWidth}" shape-rendering="crispEdges" />
260262
${lineBackgrounds}
261263
${guts}
262264
</g>
263265
</svg>`;
264-
// console.log(svg);
265266

266267
return Buffer.from(svg, "utf8");
267268
}
268269

269270
convertShikiHtmlToSvgGut(
270271
shikiHtml: string,
271272
options: ConversionOptions,
273+
diffChars: DiffChar[],
272274
): { guts: string; lineBackgrounds: string } {
273275
const dom = new JSDOM(shikiHtml);
274276
const document = dom.window.document;
275277

276278
const lines = Array.from(document.querySelectorAll(".line"));
279+
280+
const additionSegmentsByLine = new Map<
281+
number,
282+
Array<{ start: number; end: number }>
283+
>();
284+
285+
diffChars.forEach((diff) => {
286+
if (
287+
diff.type !== "new" ||
288+
diff.newLineIndex === undefined ||
289+
diff.newCharIndexInLine === undefined
290+
) {
291+
return;
292+
}
293+
294+
if (diff.char.includes("\n")) {
295+
return;
296+
}
297+
298+
const start = diff.newCharIndexInLine;
299+
const end = start + diff.char.length;
300+
const existing = additionSegmentsByLine.get(diff.newLineIndex) ?? [];
301+
existing.push({ start, end });
302+
additionSegmentsByLine.set(diff.newLineIndex, existing);
303+
});
304+
305+
additionSegmentsByLine.forEach((segments, lineIndex) => {
306+
segments.sort((a, b) => a.start - b.start);
307+
const merged: Array<{ start: number; end: number }> = [];
308+
segments.forEach((segment) => {
309+
if (merged.length === 0) {
310+
merged.push({ ...segment });
311+
return;
312+
}
313+
314+
const last = merged[merged.length - 1];
315+
if (segment.start <= last.end) {
316+
last.end = Math.max(last.end, segment.end);
317+
} else {
318+
merged.push({ ...segment });
319+
}
320+
});
321+
additionSegmentsByLine.set(lineIndex, merged);
322+
});
277323
const svgLines = lines.map((line, index) => {
278324
const spans = Array.from(line.childNodes)
279325
.map((node) => {
@@ -309,60 +355,37 @@ export class CodeRenderer {
309355
return `<text x="0" y="${y}" font-family="${options.fontFamily}" font-size="${options.fontSize.toString()}" xml:space="preserve" dominant-baseline="central" shape-rendering="crispEdges">${spans}</text>`;
310356
});
311357

358+
const estimatedCharWidth = options.fontSize * 0.6;
359+
const additionFill = "rgba(40, 167, 69, 0.25)";
360+
312361
const lineBackgrounds = lines
313362
.map((line, index) => {
314363
const classes = line?.getAttribute("class") || "";
315-
const bgColor = classes.includes("highlighted")
316-
? this.editorLineHighlight
317-
: classes.includes("diff add")
318-
? "rgba(255, 255, 0, 0.2)"
319-
: this.editorBackground;
320-
321364
const y = index * options.lineHeight;
322-
const isFirst = index === 0;
323-
const isLast = index === lines.length - 1;
324-
const isSingleLine = isFirst && isLast;
325-
const radius = 10;
326-
327-
// Handle single line case (both first and last)
328-
if (isSingleLine) {
329-
return `<path d="M ${radius} ${y}
330-
L ${options.dimensions.width - radius} ${y}
331-
Q ${options.dimensions.width} ${y} ${options.dimensions.width} ${y + radius}
332-
L ${options.dimensions.width} ${y + options.lineHeight - radius}
333-
Q ${options.dimensions.width} ${y + options.lineHeight} ${options.dimensions.width - radius} ${y + options.lineHeight}
334-
L ${radius} ${y + options.lineHeight}
335-
Q ${0} ${y + options.lineHeight} ${0} ${y + options.lineHeight - radius}
336-
L ${0} ${y + radius}
337-
Q ${0} ${y} ${radius} ${y}
338-
Z"
339-
fill="${bgColor}" />`;
365+
const segments = additionSegmentsByLine.get(index) ?? [];
366+
const backgrounds: string[] = [];
367+
368+
if (classes.includes("highlighted")) {
369+
backgrounds.push(
370+
`<rect x="0" y="${y}" width="100%" height="${options.lineHeight}" fill="${this.editorLineHighlight}" shape-rendering="crispEdges" />`,
371+
);
340372
}
341373

342-
// SVG notes:
343-
// By default SVGs have anti-aliasing on.
344-
// This is undesirable in our case because pixel-perfect alignment of these rectangles will introduce thin gaps.
345-
// Turning it off with 'shape-rendering="crispEdges"' solves the issue.
346-
return isFirst
347-
? `<path d="M ${0} ${y + options.lineHeight}
348-
L ${0} ${y + radius}
349-
Q ${0} ${y} ${radius} ${y}
350-
L ${options.dimensions.width - radius} ${y}
351-
Q ${options.dimensions.width} ${y} ${options.dimensions.width} ${y + radius}
352-
L ${options.dimensions.width} ${y + options.lineHeight}
353-
Z"
354-
fill="${bgColor}" />`
355-
: isLast
356-
? `<path d="M ${0} ${y}
357-
L ${0} ${y + options.lineHeight - radius}
358-
Q ${0} ${y + options.lineHeight} ${radius} ${y + options.lineHeight}
359-
L ${options.dimensions.width - radius} ${y + options.lineHeight}
360-
Q ${options.dimensions.width} ${y + options.lineHeight} ${options.dimensions.width} ${y + options.lineHeight - 10}
361-
L ${options.dimensions.width} ${y}
362-
Z"
363-
fill="${bgColor}" />`
364-
: `<rect x="0" y="${y}" width="100%" height="${options.lineHeight}" fill="${bgColor}" shape-rendering="crispEdges" />`;
374+
segments.forEach(({ start, end }) => {
375+
const widthInChars = Math.max(end - start, 0);
376+
if (widthInChars <= 0) {
377+
return;
378+
}
379+
const x = start * estimatedCharWidth;
380+
const segmentWidth = widthInChars * estimatedCharWidth;
381+
backgrounds.push(
382+
`<rect x="${x}" y="${y}" width="${segmentWidth}" height="${options.lineHeight}" fill="${additionFill}" shape-rendering="crispEdges" />`,
383+
);
384+
});
385+
386+
return backgrounds.join("\n");
365387
})
388+
.filter((bg) => bg.length > 0)
366389
.join("\n");
367390

368391
return {
@@ -394,6 +417,7 @@ export class CodeRenderer {
394417
options: ConversionOptions,
395418
currLineOffsetFromTop: number,
396419
newDiffLines: DiffLine[],
420+
newDiffChars: DiffChar[],
397421
): Promise<DataUri> {
398422
switch (options.imageType) {
399423
// case "png":
@@ -413,6 +437,7 @@ export class CodeRenderer {
413437
options,
414438
currLineOffsetFromTop,
415439
newDiffLines,
440+
newDiffChars,
416441
);
417442
return `data:image/svg+xml;base64,${svgBuffer.toString("base64")}`;
418443
}

core/llm/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,10 @@ export abstract class BaseLLM implements ILLM {
10561056
const msg = fromChatResponse(response);
10571057
yield msg;
10581058
completion = this._formatChatMessage(msg);
1059+
interaction?.logItem({
1060+
kind: "message",
1061+
message: msg,
1062+
});
10591063
} else {
10601064
// Stream true
10611065
const stream = this.openaiAdapter.chatCompletionStream(

extensions/vscode/src/activation/NextEditWindowManager.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ const SVG_CONFIG = {
4141
// filter: `drop-shadow(4px 4px 0px rgba(112, 114, 209, 0.4))
4242
// drop-shadow(8px 8px 0px rgba(107, 166, 205, 0.3))
4343
// drop-shadow(12px 12px 0px rgba(136, 194, 163, 0.2));`,
44-
filter: `drop-shadow(4px 4px 0px rgba(112, 114, 209, 0.4))
45-
drop-shadow(-2px 4px 0px rgba(107, 166, 205, 0.3))
46-
drop-shadow(4px -2px 0px rgba(136, 194, 163, 0.2))
47-
drop-shadow(-2px -2px 0px rgba(112, 114, 209, 0.2));`,
44+
// filter: `drop-shadow(4px 4px 0px rgba(112, 114, 209, 0.4))
45+
// drop-shadow(-2px 4px 0px rgba(107, 166, 205, 0.3))
46+
// drop-shadow(4px -2px 0px rgba(136, 194, 163, 0.2))
47+
// drop-shadow(-2px -2px 0px rgba(112, 114, 209, 0.2));`,
48+
filter: "none",
4849
radius: 3,
4950
leftMargin: 40,
5051
defaultText: "",
@@ -413,6 +414,8 @@ export class NextEditWindowManager {
413414
);
414415
}
415416

417+
const diffChars = myersCharDiff(oldEditRangeSlice, newEditRangeSlice);
418+
416419
// Create and apply decoration with the text.
417420
if (newEditRangeSlice !== "") {
418421
try {
@@ -423,6 +426,7 @@ export class NextEditWindowManager {
423426
newEditRangeSlice,
424427
this.editableRegionStartLine,
425428
diffLines,
429+
diffChars,
426430
);
427431
} catch (error) {
428432
console.error("Failed to render window:", error);
@@ -432,8 +436,6 @@ export class NextEditWindowManager {
432436
}
433437
}
434438

435-
const diffChars = myersCharDiff(oldEditRangeSlice, newEditRangeSlice);
436-
437439
this.renderDeletions(editor, diffChars);
438440

439441
// Reserve tab and esc to either accept or reject the displayed next edit contents.
@@ -686,6 +688,7 @@ export class NextEditWindowManager {
686688
text: string,
687689
currLineOffsetFromTop: number,
688690
newDiffLines: DiffLine[],
691+
diffChars: DiffChar[],
689692
): Promise<
690693
| { uri: vscode.Uri; dimensions: { width: number; height: number } }
691694
| undefined
@@ -710,6 +713,7 @@ export class NextEditWindowManager {
710713
},
711714
currLineOffsetFromTop,
712715
newDiffLines,
716+
diffChars,
713717
);
714718

715719
return {
@@ -733,12 +737,14 @@ export class NextEditWindowManager {
733737
position: vscode.Position,
734738
editableRegionStartLine: number,
735739
newDiffLines: DiffLine[],
740+
diffChars: DiffChar[],
736741
): Promise<vscode.TextEditorDecorationType | undefined> {
737742
const currLineOffsetFromTop = position.line - editableRegionStartLine;
738743
const uriAndDimensions = await this.createCodeRender(
739744
predictedCode,
740745
currLineOffsetFromTop,
741746
newDiffLines,
747+
diffChars,
742748
);
743749
if (!uriAndDimensions) {
744750
return undefined;
@@ -857,6 +863,7 @@ export class NextEditWindowManager {
857863
predictedCode: string,
858864
editableRegionStartLine: number,
859865
newDiffLines: DiffLine[],
866+
diffChars: DiffChar[],
860867
) {
861868
// Capture document version to detect changes.
862869
const docVersion = editor.document.version;
@@ -868,6 +875,7 @@ export class NextEditWindowManager {
868875
position,
869876
editableRegionStartLine,
870877
newDiffLines,
878+
diffChars,
871879
);
872880
if (!decoration) {
873881
console.error("Failed to create decoration for text:", predictedCode);
@@ -944,7 +952,6 @@ export class NextEditWindowManager {
944952

945953
const deleteDecorationType = vscode.window.createTextEditorDecorationType({
946954
backgroundColor: "rgba(255, 0, 0, 0.5)",
947-
textDecoration: "line-through",
948955
});
949956

950957
editor.setDecorations(deleteDecorationType, charsToDelete);

0 commit comments

Comments
 (0)