From e514cc7b9cbaa43d7978baad086cce68fab1c16e Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 21 Mar 2025 09:04:55 +0100 Subject: [PATCH 1/6] Add a function for parsing code diffs returned by the LLM --- .../app/lib/search-replace-blocks-parsing.ts | 137 +++++++++++++ .../host/tests/unit/code-patching-test.ts | 181 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 packages/host/app/lib/search-replace-blocks-parsing.ts create mode 100644 packages/host/tests/unit/code-patching-test.ts diff --git a/packages/host/app/lib/search-replace-blocks-parsing.ts b/packages/host/app/lib/search-replace-blocks-parsing.ts new file mode 100644 index 0000000000..3f2fa1056d --- /dev/null +++ b/packages/host/app/lib/search-replace-blocks-parsing.ts @@ -0,0 +1,137 @@ +export interface ParsedCodeContent { + fileUrl: string | null; + code: string; // only code, without the file url and diff markers (for the code editor) + searchStartLine: number | null; + searchEndLine: number | null; + replaceStartLine: number | null; + replaceEndLine: number | null; + contentWithoutFileUrl: string | null; // diff markers and code, but file url is removed +} + +// This function will take an input from a code block that the LLM responded with, +// and it will try to recognize this pattern: + +// // File url: https://example.com/file.txt +// <<<<<<< SEARCH +// code to search +// ======= +// code to replace +// >>>>>>> REPLACE + +// It will parse it and return an object with the following properties: +// - fileUrl: needed for ApplySearchReplaceBlockCommand +// - contentWithoutFileUrl: needed for ApplySearchReplaceBlockCommand +// - code: code, without the file url and diff markers (this is fed into the code editor) +// - searchStartLine: where the search block starts (for code editor diff decorations) +// - searchEndLine: where the search block ends (for code editor diff decorations) +// - replaceStartLine: where the replace block starts (for code editor diff decorations) +// - replaceEndLine: where the replace block ends (for code editor diff decorations) + +// This code was mostly generated by https://x.ai/grok by feeding it tests from code-patching-test.ts, +// and edited to fix edge cases. +export function parseCodeContent(input: string): ParsedCodeContent { + const lines = input.split('\n'); + + // Extract file URL if present + let fileUrl: string | null = null; + const fileUrlIndex = lines.findIndex((line) => + line.startsWith('// File url: '), + ); + if (fileUrlIndex !== -1) { + fileUrl = lines[fileUrlIndex].substring('// File url: '.length).trim(); + } + + let contentWithoutFileUrl; + if (fileUrl) { + contentWithoutFileUrl = lines.slice(fileUrlIndex + 1).join('\n'); + } + + // Find diff markers + const searchStart = lines.indexOf('<<<<<<< SEARCH'); + const separator = lines.indexOf('=======', searchStart + 1); + const replaceEnd = lines.indexOf('>>>>>>> REPLACE', separator + 1); + const hasCompleteDiff = + searchStart !== -1 && + separator !== -1 && + replaceEnd !== -1 && + searchStart < separator && + separator < replaceEnd; + + let codeLines: string[] = []; + let searchLines: number[] = []; + let replaceLines: number[] = []; + + if (hasCompleteDiff) { + // Complete diff: extract search and replace blocks + const searchCodeLines = lines.slice(searchStart + 1, separator); + const replaceCodeLines = lines.slice(separator + 1, replaceEnd); + codeLines = [...searchCodeLines, ...replaceCodeLines]; + searchLines = Array.from({ length: searchCodeLines.length }, (_, i) => i); + replaceLines = Array.from( + { length: replaceCodeLines.length }, + (_, i) => i + searchCodeLines.length, + ); + } else if (searchStart !== -1 && separator !== -1) { + // Unfinished replace block: search block and partial replace block + const searchCodeLines = lines.slice(searchStart + 1, separator); + const replaceCodeLines = lines.slice(separator + 1); + codeLines = [...searchCodeLines, ...replaceCodeLines]; + searchLines = Array.from({ length: searchCodeLines.length }, (_, i) => i); + replaceLines = Array.from( + { length: replaceCodeLines.length }, + (_, i) => i + searchCodeLines.length, + ); + } else if (searchStart !== -1) { + // Unfinished search block: only search block present + const searchCodeLines = lines.slice(searchStart + 1); + codeLines = searchCodeLines; + searchLines = Array.from({ length: searchCodeLines.length }, (_, i) => i); + } else { + // No diff markers: return all lines except file URL + codeLines = lines.filter((_, i) => i !== fileUrlIndex); + } + + // Remove trailing empty lines from codeLines and adjust line arrays + while ( + codeLines.length > 0 && + codeLines[codeLines.length - 1].trim() === '' + ) { + codeLines.pop(); + if (replaceLines.length > 0) { + replaceLines.pop(); + } else if (searchLines.length > 0) { + searchLines.pop(); + } + } + + // Normalize leading whitespace if codeLines are not empty + if (codeLines.length > 0) { + const minLeadingSpaces = Math.min( + ...codeLines + .filter((line) => line.trim() !== '') + .map((line) => line.match(/^\s*/)?.[0].length || 0), + ); + codeLines = codeLines.map((line) => line.substring(minLeadingSpaces)); + } + + // Join lines into final code string + const code = codeLines.join('\n'); + + // Set line numbers (1-based for output) + const searchStartLine = searchLines.length > 0 ? searchLines[0] + 1 : null; + const searchEndLine = + searchLines.length > 0 ? searchLines[searchLines.length - 1] + 1 : null; + const replaceStartLine = replaceLines.length > 0 ? replaceLines[0] + 1 : null; + const replaceEndLine = + replaceLines.length > 0 ? replaceLines[replaceLines.length - 1] + 1 : null; + + return { + fileUrl, + code, + searchStartLine, + searchEndLine, + replaceStartLine, + replaceEndLine, + contentWithoutFileUrl: contentWithoutFileUrl ?? null, + }; +} diff --git a/packages/host/tests/unit/code-patching-test.ts b/packages/host/tests/unit/code-patching-test.ts new file mode 100644 index 0000000000..0fdbbfa765 --- /dev/null +++ b/packages/host/tests/unit/code-patching-test.ts @@ -0,0 +1,181 @@ +import { module, test } from 'qunit'; + +import { parseCodeContent } from '@cardstack/host/lib/search-replace-blocks-parsing'; + +module( + 'Unit | code patching | parse search replace blocks', + function (_assert) { + test('will parse a search replace block when block is present', async function (assert) { + let diff = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH +
+
+======= +
+

Basketball

+
+>>>>>>> REPLACE +`; + + let result = parseCodeContent(diff); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + assert.strictEqual( + result.code, + ` +
+
+
+

Basketball

+
`.trimStart(), + ); // code without the search and replace markers + assert.strictEqual(result.searchStartLine, 1); + assert.strictEqual(result.searchEndLine, 2); + assert.strictEqual(result.replaceStartLine, 3); + assert.strictEqual(result.replaceEndLine, 5); + }); + + test('will not parse a search replace block when block is present', async function (assert) { + let code = ` +console.log('hello'); +for (let i = 0; i < 10; i++) { + console.log(i); +}`.trimStart(); + + let result = parseCodeContent(code); + assert.strictEqual(result.code, code); + assert.strictEqual(result.searchStartLine, null); + assert.strictEqual(result.searchEndLine, null); + assert.strictEqual(result.replaceStartLine, null); + assert.strictEqual(result.replaceEndLine, null); + assert.strictEqual(result.fileUrl, null); + }); + + test('will parse a search replace block when search block is not finished (case 1)', async function (assert) { + let code = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH +
+
+`.trimStart(); + + let expectedCode = ` +
+
`.trimStart(); + + let result = parseCodeContent(code); + assert.strictEqual(result.code, expectedCode); + assert.strictEqual(result.searchStartLine, 1); + assert.strictEqual(result.searchEndLine, 2); + assert.strictEqual(result.replaceStartLine, null); + assert.strictEqual(result.replaceEndLine, null); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + }); + + test('will parse a search replace block when search block is not finished (case 2)', async function (assert) { + let code = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH +
`.trimStart(); + + let expectedCode = ` +
`.trimStart(); + + let result = parseCodeContent(code); + assert.strictEqual(result.code, expectedCode); + assert.strictEqual(result.searchStartLine, 1); + assert.strictEqual(result.searchEndLine, 1); + assert.strictEqual(result.replaceStartLine, null); + assert.strictEqual(result.replaceEndLine, null); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + }); + + test('will parse a search replace block when search block is not finished (case 3)', async function (assert) { + let code = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH`.trimStart(); + + let expectedCode = ''; + + let result = parseCodeContent(code); + assert.strictEqual(result.code, expectedCode); + assert.strictEqual(result.searchStartLine, null); + assert.strictEqual(result.searchEndLine, null); + assert.strictEqual(result.replaceStartLine, null); + assert.strictEqual(result.replaceEndLine, null); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + }); + + test('will parse a search replace block when replace block is not finished (case 1)', async function (assert) { + let diff = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH +
+
+======= +
+

Basketball

+`; + + let expectedCode = ` +
+
+
+

Basketball

`.trimStart(); + + let result = parseCodeContent(diff); + assert.strictEqual(result.code, expectedCode); + assert.strictEqual(result.searchStartLine, 1); + assert.strictEqual(result.searchEndLine, 2); + assert.strictEqual(result.replaceStartLine, 3); + assert.strictEqual(result.replaceEndLine, 4); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + }); + + test('will parse a search replace block when replace block is not finished (case 2)', async function (assert) { + let diff = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH +
+
+======= +
+`; + + let expectedCode = ` +
+
+
`.trimStart(); + + let result = parseCodeContent(diff); + assert.strictEqual(result.code, expectedCode); + assert.strictEqual(result.searchStartLine, 1); + assert.strictEqual(result.searchEndLine, 2); + assert.strictEqual(result.replaceStartLine, 3); + assert.strictEqual(result.replaceEndLine, 3); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + }); + + test('will parse a search replace block when replace block is not finished (case 3)', async function (assert) { + let diff = ` +// File url: https://example.com/file.txt +<<<<<<< SEARCH +
+
+======= +`; + + let expectedCode = ` +
+
`.trimStart(); + + let result = parseCodeContent(diff); + assert.strictEqual(result.code, expectedCode); + assert.strictEqual(result.searchStartLine, 1); + assert.strictEqual(result.searchEndLine, 2); + assert.strictEqual(result.replaceStartLine, null); + assert.strictEqual(result.replaceEndLine, null); + assert.strictEqual(result.fileUrl, 'https://example.com/file.txt'); + }); + }, +); From 86cf7d4a13d0a5f151d77447aa7e02adfd6535ec Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 21 Mar 2025 09:05:58 +0100 Subject: [PATCH 2/6] Show colored diffs (red, green) when assistant suggests code changes --- .../components/ai-assistant/code-block.gts | 188 ++++++++++++------ .../ai-assistant/formatted-message.gts | 69 +++---- .../matrix/room-message-command.gts | 4 +- 3 files changed, 152 insertions(+), 109 deletions(-) diff --git a/packages/host/app/components/ai-assistant/code-block.gts b/packages/host/app/components/ai-assistant/code-block.gts index 93ca42bc76..561c0e39f3 100644 --- a/packages/host/app/components/ai-assistant/code-block.gts +++ b/packages/host/app/components/ai-assistant/code-block.gts @@ -22,7 +22,10 @@ import { MonacoSDK } from '@cardstack/host/services/monaco-service'; import ApplyButton from '../ai-assistant/apply-button'; +import { CodeData } from './formatted-message'; + import type { ComponentLike } from '@glint/template'; +import type * as _MonacoSDK from 'monaco-editor'; interface CopyCodeButtonSignature { Args: { code?: string; @@ -31,14 +34,14 @@ interface CopyCodeButtonSignature { interface ApplyCodePatchButtonSignature { Args: { - codePatch: string; - fileUrl: string; + codePatch?: string | null; + fileUrl?: string | null; }; } interface CodeBlockActionsSignature { Args: { - code?: string; + codeData?: Partial; }; Blocks: { default: [ @@ -58,8 +61,7 @@ interface CodeBlockEditorSignature { interface Signature { Args: { monacoSDK: MonacoSDK; - code: string; - language: string; + codeData: Partial; }; Blocks: { default: [ @@ -72,41 +74,67 @@ interface Signature { Element: HTMLElement; } -import type * as _MonacoSDK from 'monaco-editor'; - -export default class CodeBlock extends Component { - @tracked copyCodeButtonText: 'Copy' | 'Copied!' = 'Copy'; - - copyCode = restartableTask(async (code: string) => { - this.copyCodeButtonText = 'Copied!'; - await navigator.clipboard.writeText(code); - await timeout(1000); - this.copyCodeButtonText = 'Copy'; - }); +let CodeBlockComponent: TemplateOnlyComponent = ; - -} +export default CodeBlockComponent; interface MonacoEditorSignature { Args: { Named: { - code: string; - language: string; + codeData: Partial; monacoSDK: MonacoSDK; editorDisplayOptions: MonacoEditorOptions; }; }; } +function applyCodeDiffDecorations( + editor: _MonacoSDK.editor.IStandaloneCodeEditor, + monacoSDK: MonacoSDK, + codeData: Partial, +) { + if (codeData.searchStartLine && codeData.searchEndLine) { + editor.deltaDecorations( + [], + [ + { + range: new monacoSDK.Range( + codeData.searchStartLine, + 0, + codeData.searchEndLine, + 1000, // Arbitrary large number to ensure the decoration spans the entire line + ), + options: { inlineClassName: 'line-to-be-replaced' }, + }, + ], + ); + } + + if (codeData.replaceStartLine && codeData.replaceEndLine) { + editor.deltaDecorations( + [], + [ + { + range: new monacoSDK.Range( + codeData.replaceStartLine, + 0, + codeData.replaceEndLine, + 1000, // Arbitrary large number to ensure the decoration spans the entire line + ), + options: { inlineClassName: 'line-to-be-replaced-with' }, + }, + ], + ); + } +} + class MonacoEditor extends Modifier { private monacoState: { editor: _MonacoSDK.editor.IStandaloneCodeEditor; @@ -115,12 +143,15 @@ class MonacoEditor extends Modifier { element: HTMLElement, _positional: [], { - code, - language, + codeData, monacoSDK, editorDisplayOptions, }: MonacoEditorSignature['Args']['Named'], ) { + let { code, language } = codeData; + if (!code || !language) { + return; + } if (this.monacoState) { let { editor } = this.monacoState; let model = editor.getModel()!; @@ -131,37 +162,58 @@ class MonacoEditor extends Modifier { // then apply that delta to the model. Compared to calling setValue() // for every new value, this removes the need for re-tokenizing the code // which is expensive and produces visual annoyances such as flickering. - let currentCode = model.getValue(); - let newCode = code; - let codeDelta = newCode.slice(currentCode.length); - - let lineCount = model.getLineCount(); - let lastLineLength = model.getLineLength(lineCount); - - let range = { - startLineNumber: lineCount, - startColumn: lastLineLength + 1, - endLineNumber: lineCount, - endColumn: lastLineLength + 1, - }; - let editOperation = { - range: range, - text: codeDelta, - forceMoveMarkers: true, - }; - - let withDisabledReadOnly = (readOnlySetting: boolean, fn: () => void) => { - editor.updateOptions({ readOnly: false }); - fn(); - editor.updateOptions({ readOnly: readOnlySetting }); - }; + let currentCode = model.getValue(); + let newCode = code ?? ''; + + if (!newCode.startsWith(currentCode)) { + // This is a safety net for rare cases where the new code streamed in + // does not begin with the current code. This can happen when streaming + // in code with search/replace diff markers and the diff marker in chunk + // is incomplete, for example "<<<<<<< SEAR" instead of + // "<<<<<<< SEARCH". In this case the code diff parsing logic + // in parseCodeContent will not recognize the diff marker and it will + // display "<<<<<<< SEAR" for a brief moment in the editor, before getting + // a chunk with a complete diff marker. In this case we need to reset + // the data otherwise the appending delta will be incorrect and we'll + // see mangled code in the editor (syntax errors with incomplete diff markers). + model.setValue(newCode); + } else { + let codeDelta = newCode.slice(currentCode.length); + + let lineCount = model.getLineCount(); + let lastLineLength = model.getLineLength(lineCount); + + let range = { + startLineNumber: lineCount, + startColumn: lastLineLength + 1, + endLineNumber: lineCount, + endColumn: lastLineLength + 1, + }; + + let editOperation = { + range: range, + text: codeDelta, + forceMoveMarkers: true, + }; + + let withDisabledReadOnly = ( + readOnlySetting: boolean, + fn: () => void, + ) => { + editor.updateOptions({ readOnly: false }); + fn(); + editor.updateOptions({ readOnly: readOnlySetting }); + }; + + withDisabledReadOnly(!!editorDisplayOptions.readOnly, () => { + editor.executeEdits('append-source', [editOperation]); + }); - withDisabledReadOnly(!!editorDisplayOptions.readOnly, () => { - editor.executeEdits('append-source', [editOperation]); - }); + editor.revealLine(lineCount + 1); // Scroll to the end as the code streams - editor.revealLine(lineCount + 1); // Scroll to the end as the code streams + applyCodeDiffDecorations(editor, monacoSDK, codeData); + } } else { let monacoContainer = element; @@ -174,6 +226,7 @@ class MonacoEditor extends Modifier { monacoSDK.editor.setModelLanguage(model, language); model.setValue(code); + applyCodeDiffDecorations(editor, monacoSDK, codeData); this.monacoState = { editor, @@ -206,6 +259,12 @@ class CodeBlockEditor extends Component {