diff --git a/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte
new file mode 100644
index 0000000000..6f74ff7a80
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte
@@ -0,0 +1,170 @@
+
+
+
+
+
+ {#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)}
+ {#if row.kind === 'collapsed' || row.kind === 'omitted'}
+
+ {:else}
+
+ {row.oldLine ?? ''}
+
+ {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''}
+
+
+ {#each row.changedRanges ?? [] as range}
+
+ {/each}
+ {@html highlightedLine(row) || ' '}
+
+
+ {/if}
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte
new file mode 100644
index 0000000000..a2ab9d341c
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte
@@ -0,0 +1,103 @@
+
+
+{#snippet headerRight()}
+
+ {#if counts && (counts.added > 0 || counts.removed > 0)}
+
+ +{counts.added}
+ −{counts.removed}
+
+ {/if}
+ {#if showPreviewChip && message.previewCard}
+
+ {/if}
+
+{/snippet}
+
+ (toggled = { id: toggleKey, open: !expanded })}
+ toggleable={hasBody}
+ shimmer={isRunning}
+ class={message.isQueued && !message.error
+ ? 'opacity-60 hover:opacity-100 transition-opacity'
+ : ''}
+ labelClass="truncate"
+ contentClass="p-0 overflow-hidden space-y-0"
+ {headerRight}
+>
+ {#if diff && hasDiff && expanded}
+
+ {/if}
+ {#if message.error}
+
+ {message.error}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte
index 8d945f94d1..d5fee5a4d9 100644
--- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte
@@ -40,6 +40,8 @@
import ToolPreviewCard from './ToolPreviewCard.svelte'
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
import RunScriptCard from './RunScriptCard.svelte'
+ import ToolDiffCard from './ToolDiffCard.svelte'
+ import { hasToolCodeDiff } from './toolCodeDiff'
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte'
@@ -132,6 +134,7 @@
// A call that inspected a run rather than starting one gets the same card, bound to
// the job it named — what happened in a run reads the same either way.
const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun))
+ const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName))
// The preview chip sits on the header row (to the right of the tool-call text);
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
@@ -170,6 +173,8 @@
{:else if isRunCard}
+{:else if isDiffCard}
+
{:else if planState}
diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts
index 7a86e0ba76..a1755bcaa9 100644
--- a/frontend/src/lib/components/copilot/chat/global/core.test.ts
+++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts
@@ -2024,6 +2024,24 @@ describe('global AI tools', () => {
})
})
+ it('records a creation diff for a new script draft', async () => {
+ const statuses: any[] = []
+
+ await callGlobalTool(
+ 'write_script',
+ {
+ path: 'f/scripts/new-script',
+ language: 'python3',
+ content: 'print("hello")'
+ },
+ { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) }
+ )
+
+ expect(statuses).toContainEqual({
+ codeDiff: { before: '', after: 'print("hello")', lang: 'python' }
+ })
+ })
+
it('tells a code app from a drag-and-drop app', async () => {
vi.mocked(AppService.listApps).mockResolvedValueOnce([
{ path: 'f/apps/code', summary: 'Code app', raw_app: true },
@@ -3080,12 +3098,17 @@ describe('global AI tools', () => {
kind: 'script'
} as any)
- await callGlobalTool('write_script', {
- path: 'f/scripts/existing',
- summary: 'new summary',
- language: 'bun',
- content: 'new content'
- })
+ const statuses: any[] = []
+ await callGlobalTool(
+ 'write_script',
+ {
+ path: 'f/scripts/existing',
+ summary: 'new summary',
+ language: 'bun',
+ content: 'new content'
+ },
+ { ...toolCallbacks, setToolStatus: (_toolId, status) => statuses.push(status) }
+ )
expect(
getBackendDraft('script', 'f/scripts/existing', { workspace: WORKSPACE })
@@ -3097,6 +3120,9 @@ describe('global AI tools', () => {
content: 'new content',
language: 'bun'
})
+ expect(statuses).toContainEqual({
+ codeDiff: { before: 'old deployed content', after: 'new content', lang: 'typescript' }
+ })
})
describe('stale-draft deploy guard and rebase', () => {
diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts
index 5b0490c724..a4581bd173 100644
--- a/frontend/src/lib/components/copilot/chat/global/core.ts
+++ b/frontend/src/lib/components/copilot/chat/global/core.ts
@@ -137,8 +137,10 @@ import {
type RunFormDisplay,
type Tool,
type ToolCallbacks,
+ type ToolCodeDiff,
type ToolDisplayAction
} from '../shared'
+import { scriptLangToEditorLang } from '$lib/scripts'
import { searchDocsTool, readDocsPageTool } from '../docs/core'
import { createDbSchemaTool } from '../script/core'
import type { ContextElement } from '../context'
@@ -5029,10 +5031,12 @@ function finishAppDraftWrite(
function finishDraftWrite(
result: DraftPersistResult,
existed: boolean,
- ctx: WriteDraftCtx
+ ctx: WriteDraftCtx,
+ codeDiff?: ToolCodeDiff
): string {
const failure = draftWriteFailure(result, ctx)
if (failure) return failure
+ if (codeDiff) ctx.toolCallbacks.setToolStatus(ctx.toolId, { codeDiff })
ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath)
maybeAttachPreviewCard(ctx, result.itemKind, result.item.path)
const stored = result.item
@@ -5078,7 +5082,11 @@ async function writeDraft(
path: string,
args: A,
ctx: WriteDraftCtx,
- opts: { triggerKind?: TriggerKind; override?: boolean } = {}
+ opts: {
+ triggerKind?: TriggerKind
+ override?: boolean
+ codeDiff?: ToolCodeDiff | ((base: T | undefined, draft: T) => ToolCodeDiff | undefined)
+ } = {}
): Promise {
const { workspace } = ctx
startDraftWrite(ctx, type, path)
@@ -5097,7 +5105,8 @@ async function writeDraft(
triggerKind: opts.triggerKind,
force: opts.override
})
- return finishDraftWrite(result, existed, ctx)
+ const codeDiff = typeof opts.codeDiff === 'function' ? opts.codeDiff(base, draft) : opts.codeDiff
+ return finishDraftWrite(result, existed, ctx, codeDiff)
}
type ScriptDraftArgs = {
@@ -5150,8 +5159,21 @@ const SCRIPT_SPEC: WriteSpec = {
}
}
-function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise {
- return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override })
+function writeScriptDraft(
+ args: ScriptDraftArgs,
+ ctx: WriteDraftCtx,
+ codeDiff?: ToolCodeDiff
+): Promise {
+ return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, {
+ override: args.override,
+ codeDiff:
+ codeDiff ??
+ ((base, draft) => ({
+ before: base?.content ?? '',
+ after: draft.content,
+ lang: scriptLangToEditorLang(draft.language)
+ }))
+ })
}
type FlowDraftArgs = {
@@ -5368,7 +5390,8 @@ async function editScript(
language: base.language,
content: updated
},
- ctx
+ ctx,
+ { before: base.content, after: updated, lang: scriptLangToEditorLang(base.language) }
)
}
diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts
new file mode 100644
index 0000000000..f235ae3796
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('monaco-editor', () => ({
+ editor: {},
+ languages: {},
+ KeyCode: {},
+ Uri: { parse: (value: string) => ({ toString: () => value }) },
+ MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }
+}))
+vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
+ getTypeScriptWorker: async () => async () => ({}),
+ typescriptVersion: 'test'
+}))
+vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
+vi.mock('$lib/components/vscode', () => ({}))
+
+import { editCodeToolWithDiff } from './core'
+
+describe('editCodeToolWithDiff', () => {
+ it('records the complete script diff after applying replacements', async () => {
+ const statuses: unknown[] = []
+ const applyCode = vi.fn(async () => {})
+
+ await editCodeToolWithDiff.fn({
+ args: { diffs: [{ old_string: 'return 1', new_string: 'return 2' }] },
+ helpers: {
+ getScriptOptions: () => ({
+ code: 'export function main() {\n\treturn 1\n}',
+ lang: 'bun',
+ path: 'f/example',
+ args: {}
+ }),
+ applyCode
+ },
+ toolCallbacks: {
+ setToolStatus: (_toolId, status) => statuses.push(status)
+ },
+ toolId: 'edit-1'
+ })
+
+ expect(applyCode).toHaveBeenCalledTimes(2)
+ expect(statuses.at(-1)).toMatchObject({
+ codeDiff: {
+ before: 'export function main() {\n\treturn 1\n}',
+ after: 'export function main() {\n\treturn 2\n}',
+ lang: 'typescript'
+ }
+ })
+ })
+})
diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts
index 622f182eae..0c8b3f9a0e 100644
--- a/frontend/src/lib/components/copilot/chat/script/core.ts
+++ b/frontend/src/lib/components/copilot/chat/script/core.ts
@@ -27,6 +27,7 @@ import type { ReviewChangesOpts } from '../monaco-adapter'
import { copilotInfo, getCurrentModel } from '$lib/aiStore'
import { get } from 'svelte/store'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
+import { scriptLangToEditorLang } from '$lib/scripts'
import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts'
// Score threshold for npm packages search filtering
@@ -826,7 +827,12 @@ export const editCodeToolWithDiff: Tool = {
toolCallbacks.setToolStatus(toolId, {
content: `Code changes applied`,
- result: 'Success'
+ result: 'Success',
+ codeDiff: {
+ before: oldCode,
+ after: updatedCode,
+ lang: scriptLangToEditorLang(scriptOptions.lang)
+ }
})
return `Applied changes to the script editor.`
} catch (error) {
@@ -878,7 +884,12 @@ export const editCodeTool: Tool = {
toolCallbacks.setToolStatus(toolId, {
content: 'Code changes applied',
- result: 'Success'
+ result: 'Success',
+ codeDiff: {
+ before: oldCode,
+ after: args.code,
+ lang: scriptLangToEditorLang(scriptOptions.lang)
+ }
})
return 'Code has been applied to the script editor.'
} catch (error) {
diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts
index 772284865d..83a51b527c 100644
--- a/frontend/src/lib/components/copilot/chat/shared.ts
+++ b/frontend/src/lib/components/copilot/chat/shared.ts
@@ -610,6 +610,13 @@ export type WebSearchSource = {
title?: string
}
+export type ToolCodeDiff = {
+ before: string
+ after: string
+ /** Monaco language id. */
+ lang: string
+}
+
/** The result shape any tool returns to have it rendered as a web search card. */
export type WebSearchResult = {
sources: WebSearchSource[]
@@ -725,6 +732,9 @@ export type ToolDisplayMessage = {
* always-visible card that opens (or focuses) the item's preview in the
* session side panel. Set only for session chats — the side panel is their surface. */
previewCard?: { kind: PreviewCardKind; path: string }
+ /** Whole text on both sides of an edit this call saved. Kept in full rather than as a
+ * reference: drafts are overwritten in place, so the sides cannot be fetched back later. */
+ codeDiff?: ToolCodeDiff
planArtifactId?: string
/** The version this card's proposal wrote, so a card scrolled far up still opens the plan
* it proposed rather than what the document became. */
diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts
new file mode 100644
index 0000000000..5077585a4f
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts
@@ -0,0 +1,411 @@
+import { describe, expect, it } from 'vitest'
+import hljs from 'highlight.js/lib/core'
+import {
+ argumentsCarryWholeFile,
+ diffLineCounts,
+ hasToolCodeDiff,
+ toolCodeDiff,
+ toolDiffLines,
+ visibleToolDiffRows
+} from './toolCodeDiff'
+import { highlightedSourceLines } from './toolCodeDiffHighlight'
+import { TOOL_CODE_DIFF_LANGUAGES, toolCodeDiffLanguage } from './toolCodeDiffLanguage'
+import type { ToolDisplayMessage } from './shared'
+
+function message(overrides: Partial): ToolDisplayMessage {
+ return {
+ role: 'tool',
+ tool_call_id: 'call-1',
+ content: 'Updated script "f/example" as a draft',
+ ...overrides
+ }
+}
+
+describe('toolCodeDiff', () => {
+ it('uses the saved full script sides after an edit succeeds', () => {
+ const codeDiff = {
+ before: 'const before = true\n',
+ after: 'const after = true\n',
+ lang: 'typescript'
+ }
+
+ expect(
+ toolCodeDiff(
+ message({
+ toolName: 'edit_script',
+ parameters: { old_string: 'before', new_string: 'after' },
+ codeDiff
+ })
+ )
+ ).toBe(codeDiff)
+ })
+
+ it('uses edit arguments until the full script sides are available', () => {
+ expect(
+ toolCodeDiff(
+ message({
+ toolName: 'edit_script',
+ parameters: { old_string: 'before', new_string: 'after' }
+ })
+ )
+ ).toEqual({ before: 'before', after: 'after', lang: 'plaintext' })
+ })
+
+ it('renders streamed edit arguments before their JSON object is complete', () => {
+ expect(
+ toolCodeDiff(
+ message({
+ toolName: 'edit_script',
+ parameters: '{"old_string":"before\\nline","new_string":"after'
+ })
+ )
+ ).toEqual({ before: 'before\nline', after: 'after', lang: 'plaintext' })
+ })
+
+ it('uses each replacement from an in-editor edit while its arguments stream', () => {
+ expect(
+ toolCodeDiff(
+ message({
+ toolName: 'edit_code',
+ parameters: {
+ diffs: [
+ { old_string: 'first old', new_string: 'first new' },
+ { old_string: 'second old', new_string: 'second new' }
+ ]
+ }
+ })
+ )
+ ).toEqual({
+ before: 'first old\nsecond old',
+ after: 'first new\nsecond new',
+ lang: 'plaintext'
+ })
+ })
+
+ it('uses each replacement while in-editor edit arguments are incomplete', () => {
+ expect(
+ toolCodeDiff(
+ message({
+ toolName: 'edit_code',
+ parameters:
+ '{"diffs":[{"old_string":"first old","new_string":"first new"},{"old_string":"second old","new_string":"second new'
+ })
+ )
+ ).toEqual({
+ before: 'first old\nsecond old',
+ after: 'first new\nsecond new',
+ lang: 'plaintext'
+ })
+ })
+
+ it('shows a streamed full-code editor update without replacements', () => {
+ expect(
+ toolCodeDiff(message({ toolName: 'edit_code', parameters: '{"code":"console.log(1)' }))
+ ).toEqual({ before: '', after: 'console.log(1)', lang: 'plaintext' })
+ })
+
+ it('shows streamed full-script updates before the saved diff is available', () => {
+ expect(
+ toolCodeDiff(
+ message({ toolName: 'write_script', parameters: '{"content":"print(\\\"hello\\\")' })
+ )
+ ).toEqual({ before: '', after: 'print("hello")', lang: 'plaintext' })
+ })
+
+ it('only marks tools with a defined argument diff as diff-capable', () => {
+ expect(hasToolCodeDiff('edit_script')).toBe(true)
+ expect(hasToolCodeDiff('edit_code')).toBe(true)
+ expect(hasToolCodeDiff('write_script')).toBe(true)
+ expect(hasToolCodeDiff('__proto__')).toBe(false)
+ expect(hasToolCodeDiff('toString')).toBe(false)
+ expect(hasToolCodeDiff(undefined)).toBe(false)
+ })
+
+ it('ignores tool names inherited from Object.prototype', () => {
+ expect(toolCodeDiff(message({ toolName: 'constructor', parameters: {} }))).toBeUndefined()
+ })
+
+ it('counts added and removed lines independently', () => {
+ expect(
+ diffLineCounts({
+ before: 'one\ntwo\nthree\n',
+ after: 'one\nnew\nthree\nfour\n',
+ lang: 'plaintext'
+ })
+ ).toEqual({
+ added: 2,
+ removed: 1
+ })
+ })
+
+ it('keeps a final newline as a diffable line', () => {
+ expect(toolDiffLines({ before: 'one', after: 'one\n', lang: 'plaintext' })).toEqual([
+ { kind: 'context', content: 'one', oldLine: 1, newLine: 1 },
+ { kind: 'added', content: '', newLine: 2 }
+ ])
+ })
+
+ it('does not add a final-newline row when creating or deleting a file', () => {
+ expect(toolDiffLines({ before: '', after: 'one\n', lang: 'plaintext' })).toEqual([
+ { kind: 'added', content: 'one', newLine: 1, changedRanges: [{ start: 0, length: 3 }] }
+ ])
+ expect(toolDiffLines({ before: 'one\n', after: '', lang: 'plaintext' })).toEqual([
+ { kind: 'removed', content: 'one', oldLine: 1, changedRanges: [{ start: 0, length: 3 }] }
+ ])
+ })
+
+ it('keeps both gutters correct across removed and added lines', () => {
+ expect(
+ toolDiffLines({
+ before: 'one\ntwo\nthree\n',
+ after: 'one\nnew\nthree\nfour\n',
+ lang: 'plaintext'
+ })
+ ).toEqual([
+ { kind: 'context', content: 'one', oldLine: 1, newLine: 1 },
+ {
+ kind: 'removed',
+ content: 'two',
+ oldLine: 2,
+ newLine: undefined,
+ changedRanges: [{ start: 0, length: 3 }]
+ },
+ {
+ kind: 'added',
+ content: 'new',
+ oldLine: undefined,
+ newLine: 2,
+ changedRanges: [{ start: 0, length: 3 }]
+ },
+ { kind: 'context', content: 'three', oldLine: 3, newLine: 3 },
+ {
+ kind: 'added',
+ content: 'four',
+ oldLine: undefined,
+ newLine: 4,
+ changedRanges: [{ start: 0, length: 4 }]
+ }
+ ])
+ })
+
+ it('keeps unchanged trailing lines out of the change counts', () => {
+ const diff = { before: 'one\ntwo\nthree', after: 'one\nnew\nthree', lang: 'plaintext' }
+
+ expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 })
+ expect(toolDiffLines(diff).filter((line) => line.content === 'three')).toEqual([
+ { kind: 'context', content: 'three', oldLine: 3, newLine: 3 }
+ ])
+ })
+
+ it('does not report changes for identical files', () => {
+ const diff = { before: 'one\ntwo\nthree', after: 'one\ntwo\nthree', lang: 'plaintext' }
+
+ expect(diffLineCounts(diff)).toEqual({ added: 0, removed: 0 })
+ expect(toolDiffLines(diff)).toEqual([
+ { kind: 'context', content: 'one', oldLine: 1, newLine: 1 },
+ { kind: 'context', content: 'two', oldLine: 2, newLine: 2 },
+ { kind: 'context', content: 'three', oldLine: 3, newLine: 3 }
+ ])
+ })
+
+ it('renders a newly created file', () => {
+ expect(toolDiffLines({ before: '', after: 'print("hello")', lang: 'python' })).toEqual([
+ {
+ kind: 'added',
+ content: 'print("hello")',
+ oldLine: undefined,
+ newLine: 1,
+ changedRanges: [{ start: 0, length: 14 }]
+ }
+ ])
+ })
+
+ it('renders a deleted file', () => {
+ expect(toolDiffLines({ before: 'print("goodbye")', after: '', lang: 'python' })).toEqual([
+ {
+ kind: 'removed',
+ content: 'print("goodbye")',
+ oldLine: 1,
+ newLine: undefined,
+ changedRanges: [{ start: 0, length: 16 }]
+ }
+ ])
+ })
+
+ it('uses Monaco line alignment for reordered lines', () => {
+ expect(
+ toolDiffLines({
+ before: 'const a = 1\nconst b = 2',
+ after: 'const b = 2\nconst a = 1',
+ lang: 'typescript'
+ })
+ ).toEqual([
+ {
+ kind: 'added',
+ content: 'const b = 2',
+ newLine: 1,
+ changedRanges: [{ start: 0, length: 11, extendsToEnd: true }]
+ },
+ { kind: 'context', content: 'const a = 1', oldLine: 1, newLine: 2 },
+ {
+ kind: 'removed',
+ content: 'const b = 2',
+ oldLine: 2,
+ changedRanges: [{ start: 0, length: 11 }]
+ }
+ ])
+ })
+
+ it('avoids expensive alignment while arguments stream', () => {
+ const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n')
+ const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n')
+
+ expect(toolDiffLines({ before, after, lang: 'plaintext' }, true)).toHaveLength(400)
+ expect(diffLineCounts({ before, after, lang: 'plaintext' }, true)).toEqual({
+ added: 1_000,
+ removed: 1_000
+ })
+ })
+
+ it('diffs a small edit in a large file', () => {
+ const beforeLines = Array.from({ length: 600 }, (_, index) => `line ${index}`)
+ const afterLines = beforeLines.map((line, index) => (index === 400 ? 'changed' : line))
+ const diff = { before: beforeLines.join('\n'), after: afterLines.join('\n'), lang: 'plaintext' }
+ const lines = toolDiffLines(diff)
+
+ expect(lines.filter((line) => line.kind !== 'context')).toMatchObject([
+ { kind: 'removed', content: 'line 400', oldLine: 401 },
+ { kind: 'added', content: 'changed', newLine: 401 }
+ ])
+ expect(lines[399]).toEqual({ kind: 'context', content: 'line 399', oldLine: 400, newLine: 400 })
+ expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 })
+ })
+
+ it('reports a large rewrite as a whole-file change', () => {
+ const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n')
+ const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n')
+
+ expect(diffLineCounts({ before, after, lang: 'plaintext' })).toEqual({
+ added: 1_000,
+ removed: 1_000
+ })
+ })
+
+ it('keeps an empty streamed replacement paired with its own edit', () => {
+ const parameters =
+ '{"diffs":[{"old_string":"a","new_string":""},{"old_string":"b","new_string":"c"}]}'
+
+ expect(toolCodeDiff(message({ toolName: 'edit_code', parameters }))).toEqual({
+ before: 'a\nb',
+ after: '\nc',
+ lang: 'plaintext'
+ })
+ })
+
+ it('recognizes calls whose arguments are a whole file', () => {
+ const call = (
+ toolName: string,
+ parameters: unknown,
+ codeDiff?: ToolDisplayMessage['codeDiff']
+ ) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff }))
+
+ expect(call('write_script', { path: 'f/a', content: '' })).toBe(true)
+ expect(call('edit_code', '{"code":"x')).toBe(true)
+ expect(
+ call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' })
+ ).toBe(true)
+ expect(call('edit_code', { diffs: [{ old_string: 'a', new_string: 'b' }] })).toBe(false)
+ expect(call('edit_script', { old_string: 'a', new_string: 'b' })).toBe(false)
+ })
+
+ it('shows each row of a small edit once', () => {
+ const lines = toolDiffLines({ before: 'a\nb\nc\n', after: 'a\nB\nc\n', lang: 'plaintext' })
+
+ expect(visibleToolDiffRows(lines, new Map())).toEqual(lines)
+ expect(lines.map((line) => line.kind)).toEqual(['context', 'removed', 'added', 'context'])
+ })
+
+ it('bounds the rows of a large rewrite', () => {
+ const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n')
+ const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n')
+ const lines = toolDiffLines({ before, after, lang: 'plaintext' })
+ const rows = visibleToolDiffRows(lines, new Map())
+
+ expect(rows).toHaveLength(802)
+ expect(rows[400]).toEqual({ kind: 'omitted', key: 'removed:0', count: 600 })
+ expect(rows[401]).toMatchObject({ kind: 'added', newLine: 1 })
+ expect(rows[801]).toEqual({ kind: 'omitted', key: 'added:1000', count: 600 })
+
+ // One click on a run reveals 400 more rows; the overall cap still holds until `end` is clicked.
+ const expanded = visibleToolDiffRows(lines, new Map([['removed:0', 1]]))
+ expect(expanded).toHaveLength(1_001)
+ expect(expanded[800]).toEqual({ kind: 'omitted', key: 'removed:0', count: 200 })
+ expect(expanded[1_000]).toEqual({ kind: 'omitted', key: 'end', count: 801 })
+ expect(
+ visibleToolDiffRows(
+ lines,
+ new Map([
+ ['removed:0', 1],
+ ['end', 1]
+ ])
+ )
+ ).toHaveLength(1_202)
+ })
+
+ it('highlights each line within the scope of the whole source', () => {
+ const typescript = toolCodeDiffLanguage('typescript')
+ hljs.registerLanguage(typescript.name, typescript.register)
+ const code = 'const s = `a\nb`\nconst n = 1\n'
+
+ const lines = highlightedSourceLines(code, typescript.name)
+ expect(lines).toHaveLength(3)
+ expect(lines[0]).toBe(
+ 'const s = `a'
+ )
+ expect(lines[1]).toMatch(/^b`<\/span>/)
+ expect(highlightedSourceLines(code, typescript.name, 2)).toEqual(lines.slice(0, 2))
+ expect(highlightedSourceLines('a\nb', typescript.name)).toEqual(['a', 'b'])
+ expect(highlightedSourceLines('a\nb\n', typescript.name)).toEqual(['a', 'b'])
+ expect(highlightedSourceLines('', typescript.name)).toEqual([])
+
+ const large = Array.from({ length: 2_001 }, () => 'const a = 1 < 2').join('\n') + '\n'
+ const plain = highlightedSourceLines(large, typescript.name)
+ expect(plain).toHaveLength(2_001)
+ expect(plain[0]).toBe('const a = 1 < 2')
+
+ const minified = highlightedSourceLines('x'.repeat(100_001), typescript.name)
+ expect(minified).toEqual(['x'.repeat(10_000) + '…'])
+ })
+
+ it('has a highlighter for every supported editor language', () => {
+ expect(TOOL_CODE_DIFF_LANGUAGES).toEqual(
+ expect.arrayContaining([
+ 'typescript',
+ 'javascript',
+ 'python',
+ 'json',
+ 'yaml',
+ 'sql',
+ 'shell',
+ 'powershell',
+ 'php',
+ 'rust',
+ 'graphql',
+ 'csharp',
+ 'nu',
+ 'java',
+ 'r',
+ 'go',
+ 'ruby',
+ 'plaintext'
+ ])
+ )
+ for (const language of TOOL_CODE_DIFF_LANGUAGES) {
+ const highlighter = toolCodeDiffLanguage(language)
+ hljs.registerLanguage(highlighter.name, highlighter.register)
+ expect(() =>
+ hljs.highlight('const changed = true', { language: highlighter.name })
+ ).not.toThrow()
+ }
+ })
+})
diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts
new file mode 100644
index 0000000000..2e5d8bf13c
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts
@@ -0,0 +1,381 @@
+// This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges.
+import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer'
+import type { ToolCodeDiff, ToolDisplayMessage } from './shared'
+
+// Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not
+// exist: while the arguments stream, when the call failed, and for transcripts saved before
+// calls recorded one. Argument diffs have no language or surrounding lines.
+const ARGS_DIFF_BY_TOOL: Record ToolCodeDiff | undefined> = {
+ edit_script: (params) => argumentDiff(streamingEditArguments(params)),
+ write_script: (params) => fullContentArgumentDiff(params, 'content'),
+ edit_code: (params) => {
+ if (
+ params &&
+ typeof params === 'object' &&
+ Array.isArray((params as { diffs?: unknown }).diffs)
+ ) {
+ const diffs = (params as { diffs: unknown[] }).diffs
+ return argumentDiffs(diffs.map(streamingEditArguments))
+ }
+ if (typeof params === 'string') {
+ return (
+ argumentDiffs(streamingEditArgumentsArray(params)) ??
+ fullContentArgumentDiff(params, 'code')
+ )
+ }
+ return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code')
+ }
+}
+
+function fullContentArgumentDiff(params: unknown, key: string): ToolCodeDiff | undefined {
+ const content =
+ params && typeof params === 'object'
+ ? (params as Record)[key]
+ : typeof params === 'string'
+ ? partialJsonString(params, key)
+ : undefined
+ return typeof content === 'string' ? { before: '', after: content, lang: 'plaintext' } : undefined
+}
+
+function argumentDiff(
+ edit: { old_string?: string; new_string?: string } | undefined
+): ToolCodeDiff | undefined {
+ return typeof edit?.old_string === 'string' || typeof edit?.new_string === 'string'
+ ? {
+ before: typeof edit.old_string === 'string' ? edit.old_string : '',
+ after: typeof edit.new_string === 'string' ? edit.new_string : '',
+ lang: 'plaintext'
+ }
+ : undefined
+}
+
+function argumentDiffs(
+ edits: ({ old_string?: string; new_string?: string } | undefined)[]
+): ToolCodeDiff | undefined {
+ const diffs = edits.map(argumentDiff).filter((diff): diff is ToolCodeDiff => diff !== undefined)
+ if (diffs.length === 0) return undefined
+ return {
+ before: diffs.map((diff) => diff.before).join('\n'),
+ after: diffs.map((diff) => diff.after).join('\n'),
+ lang: 'plaintext'
+ }
+}
+
+function streamingEditArguments(
+ params: unknown
+): { old_string?: string; new_string?: string } | undefined {
+ if (params && typeof params === 'object')
+ return params as { old_string?: string; new_string?: string }
+ if (typeof params !== 'string') return undefined
+
+ return {
+ old_string: partialJsonString(params, 'old_string'),
+ new_string: partialJsonString(params, 'new_string')
+ }
+}
+
+function streamingEditArgumentsArray(
+ partialJson: string
+): { old_string?: string; new_string?: string }[] {
+ const oldStrings = partialJsonStrings(partialJson, 'old_string')
+ const newStrings = partialJsonStrings(partialJson, 'new_string')
+ return Array.from({ length: Math.max(oldStrings.length, newStrings.length) }, (_, index) => ({
+ old_string: oldStrings[index],
+ new_string: newStrings[index]
+ }))
+}
+
+function partialJsonString(partialJson: string, key: string): string | undefined {
+ return partialJsonStrings(partialJson, key)[0]
+}
+
+function partialJsonStrings(partialJson: string, key: string): string[] {
+ const matches = partialJson.matchAll(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'g'))
+ const result: string[] = []
+ // An empty string is a real value (a deletion): skipping it would pair the next edit's
+ // `new_string` with this edit's `old_string`.
+ for (const match of matches) {
+ try {
+ result.push(JSON.parse(`"${match[1]}"`))
+ } catch {
+ continue
+ }
+ }
+ return result
+}
+
+export function hasToolCodeDiff(toolName: string | undefined): boolean {
+ return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName)
+}
+
+export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined {
+ if (message.codeDiff) return message.codeDiff
+ if (!message.toolName) return undefined
+ return Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName)
+ ? ARGS_DIFF_BY_TOOL[message.toolName](message.parameters)
+ : undefined
+}
+
+// Decided by tool and argument shape, never by the saved `codeDiff`: an overwrite's saved diff
+// has a before side, and clearing a script leaves both argument sides empty, yet both calls sent
+// a whole file.
+export function argumentsCarryWholeFile(message: ToolDisplayMessage): boolean {
+ if (message.toolName === 'write_script') return true
+ if (message.toolName !== 'edit_code') return false
+ const params = message.parameters
+ if (typeof params === 'string') return /"code"\s*:/.test(params)
+ return Boolean(params && typeof params === 'object' && 'code' in params)
+}
+
+export function diffLineCounts(
+ diff: ToolCodeDiff,
+ streaming = false
+): { added: number; removed: number } {
+ if (streaming) {
+ return { added: lines(diff.after).length, removed: lines(diff.before).length }
+ }
+
+ return toolDiffLineCounts(toolDiffLines(diff))
+}
+
+export function toolDiffLineCounts(lines: ToolDiffLine[]): { added: number; removed: number } {
+ let added = 0
+ let removed = 0
+ for (const line of lines) {
+ if (line.kind === 'added') added++
+ else if (line.kind === 'removed') removed++
+ }
+ return { added, removed }
+}
+
+export type ToolDiffLine = {
+ kind: 'context' | 'added' | 'removed'
+ content: string
+ oldLine?: number
+ newLine?: number
+ changedRanges?: CharacterRange[]
+}
+
+export type CharacterRange = { start: number; length: number; extendsToEnd?: boolean }
+
+const STREAMING_PREVIEW_LINES = 200
+
+function lines(value: string): string[] {
+ const result = value.split('\n')
+ if (result.at(-1) === '') result.pop()
+ return result
+}
+
+function hasFinalNewline(value: string): boolean {
+ return value.endsWith('\n')
+}
+
+export function toolDiffLines(diff: ToolCodeDiff, streaming = false): ToolDiffLine[] {
+ const before = lines(diff.before)
+ const after = lines(diff.after)
+ if (streaming) return streamingDiffLines(before, after)
+
+ const result: ToolDiffLine[] = []
+ const monacoBefore = monacoLines(diff.before)
+ const monacoAfter = monacoLines(diff.after)
+ // Runs on the UI thread. Cost follows the amount changed, not file size: only a large
+ // rewrite reaches the cap, and Monaco then reports the whole file as one change.
+ const changes = new DefaultLinesDiffComputer().computeDiff(monacoBefore, monacoAfter, {
+ ignoreTrimWhitespace: false,
+ maxComputationTimeMs: 200,
+ computeMoves: false,
+ extendToSubwords: false
+ }).changes
+ const removedRanges = new Map()
+ const addedRanges = new Map()
+ for (const change of changes) {
+ for (const innerChange of change.innerChanges ?? []) {
+ addCharacterRanges(removedRanges, monacoBefore, innerChange.originalRange)
+ addCharacterRanges(addedRanges, monacoAfter, innerChange.modifiedRange)
+ }
+ }
+
+ let oldIndex = 0
+ let newIndex = 0
+ for (const change of changes) {
+ const oldStart = change.original.startLineNumber - 1
+ const newStart = change.modified.startLineNumber - 1
+ appendContextLines(
+ result,
+ before,
+ after,
+ oldIndex,
+ newIndex,
+ Math.min(oldStart - oldIndex, newStart - newIndex)
+ )
+ appendChangedLines(
+ result,
+ 'removed',
+ before,
+ oldStart,
+ change.original.endLineNumberExclusive - 1,
+ removedRanges
+ )
+ appendChangedLines(
+ result,
+ 'added',
+ after,
+ newStart,
+ change.modified.endLineNumberExclusive - 1,
+ addedRanges
+ )
+ oldIndex = Math.min(change.original.endLineNumberExclusive - 1, before.length)
+ newIndex = Math.min(change.modified.endLineNumberExclusive - 1, after.length)
+ }
+ const trailingContext = Math.max(0, Math.min(before.length - oldIndex, after.length - newIndex))
+ appendContextLines(result, before, after, oldIndex, newIndex, trailingContext)
+ oldIndex += trailingContext
+ newIndex += trailingContext
+ appendChangedLines(result, 'removed', before, oldIndex, before.length, removedRanges)
+ appendChangedLines(result, 'added', after, newIndex, after.length, addedRanges)
+ if (
+ diff.before !== '' &&
+ diff.after !== '' &&
+ hasFinalNewline(diff.before) !== hasFinalNewline(diff.after)
+ ) {
+ result.push(
+ hasFinalNewline(diff.before)
+ ? { kind: 'removed', content: '', oldLine: before.length + 1 }
+ : { kind: 'added', content: '', newLine: after.length + 1 }
+ )
+ }
+
+ return result
+}
+
+export type VisibleToolDiffRow =
+ | ToolDiffLine
+ | { kind: 'collapsed'; key: string; count: number }
+ | { kind: 'omitted'; key: string; count: number }
+
+const CONTEXT_LINES = 3
+// Every visible row is a DOM row with no virtualization, and only context collapses: a whole-file
+// rewrite would otherwise render every line of both sides. Two capped runs fit under the overall
+// cap, so a rewrite still shows its added side. Each expansion of an omitted row reveals one more
+// cap's worth, so no single click renders an unbounded number of rows.
+const MAX_CHANGED_RUN_ROWS = 400
+const MAX_VISIBLE_ROWS = 1_000
+
+// `expansions` counts the clicks on each collapsed or omitted row, by its key.
+export function visibleToolDiffRows(
+ lines: ToolDiffLine[],
+ expansions: ReadonlyMap
+): VisibleToolDiffRow[] {
+ const result: VisibleToolDiffRow[] = []
+ for (let index = 0; index < lines.length; ) {
+ const start = index
+ const kind = lines[start].kind
+ while (index < lines.length && lines[index].kind === kind) index++
+ const count = index - start
+
+ if (kind !== 'context') {
+ const key = `${kind}:${start}`
+ const shown = MAX_CHANGED_RUN_ROWS * (1 + (expansions.get(key) ?? 0))
+ result.push(...lines.slice(start, Math.min(index, start + shown)))
+ if (count > shown) result.push({ kind: 'omitted', key, count: count - shown })
+ continue
+ }
+
+ const key = `${lines[start].oldLine}:${lines[start].newLine}:${count}`
+ if (count <= CONTEXT_LINES * 2 + 1 || expansions.has(key)) {
+ result.push(...lines.slice(start, index))
+ continue
+ }
+ result.push(...lines.slice(start, start + CONTEXT_LINES))
+ result.push({ kind: 'collapsed', key, count: count - CONTEXT_LINES * 2 })
+ result.push(...lines.slice(index - CONTEXT_LINES, index))
+ }
+
+ const maxRows = MAX_VISIBLE_ROWS * (1 + (expansions.get('end') ?? 0))
+ if (result.length <= maxRows) return result
+ const hidden = result
+ .slice(maxRows)
+ .reduce((total, row) => total + ('count' in row ? row.count : 1), 0)
+ return [...result.slice(0, maxRows), { kind: 'omitted', key: 'end', count: hidden }]
+}
+
+function streamingDiffLines(before: string[], after: string[]): ToolDiffLine[] {
+ return [
+ ...before
+ .slice(0, STREAMING_PREVIEW_LINES)
+ .map((content, index) => ({ kind: 'removed' as const, content, oldLine: index + 1 })),
+ ...after
+ .slice(0, STREAMING_PREVIEW_LINES)
+ .map((content, index) => ({ kind: 'added' as const, content, newLine: index + 1 }))
+ ]
+}
+
+function monacoLines(value: string): string[] {
+ const result = lines(value)
+ return result.length ? result : ['']
+}
+
+function appendContextLines(
+ result: ToolDiffLine[],
+ before: string[],
+ after: string[],
+ oldStart: number,
+ newStart: number,
+ count: number
+): void {
+ for (let index = 0; index < count; index++) {
+ result.push({
+ kind: 'context',
+ content: before[oldStart + index],
+ oldLine: oldStart + index + 1,
+ newLine: newStart + index + 1
+ })
+ }
+}
+
+function appendChangedLines(
+ result: ToolDiffLine[],
+ kind: 'added' | 'removed',
+ source: string[],
+ start: number,
+ end: number,
+ ranges: Map
+): void {
+ for (let index = start; index < Math.min(end, source.length); index++) {
+ const changedRanges = ranges.get(index + 1)
+ result.push({
+ kind,
+ content: source[index],
+ ...(kind === 'added' ? { newLine: index + 1 } : { oldLine: index + 1 }),
+ ...(changedRanges?.length ? { changedRanges } : {})
+ })
+ }
+}
+
+function addCharacterRanges(
+ ranges: Map,
+ lines: string[],
+ range: {
+ startLineNumber: number
+ startColumn: number
+ endLineNumber: number
+ endColumn: number
+ }
+): void {
+ for (
+ let lineNumber = range.startLineNumber;
+ lineNumber <= Math.min(range.endLineNumber, lines.length);
+ lineNumber++
+ ) {
+ const content = lines[lineNumber - 1]
+ const start = lineNumber === range.startLineNumber ? range.startColumn - 1 : 0
+ const extendsToEnd = lineNumber < range.endLineNumber
+ const end = extendsToEnd ? content.length : range.endColumn - 1
+ if (end > start) {
+ ranges.set(lineNumber, [
+ ...(ranges.get(lineNumber) ?? []),
+ { start, length: end - start, ...(extendsToEnd ? { extendsToEnd } : {}) }
+ ])
+ }
+ }
+}
diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts
new file mode 100644
index 0000000000..b63d002e9e
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts
@@ -0,0 +1,57 @@
+import hljs from 'highlight.js/lib/core'
+import { escapeHtml } from '$lib/utils'
+
+// highlight.js runs on the UI thread at roughly 15 ms per 1,000 lines (50 KB), and must see the
+// whole source for multi-line strings and comments to scope correctly. Larger sides, including
+// minified one-line sources, render as plain text with each line cut at MAX_PLAIN_LINE_CHARS.
+const MAX_HIGHLIGHTED_LINES = 2_000
+const MAX_HIGHLIGHTED_CHARS = 100_000
+const MAX_PLAIN_LINE_CHARS = 10_000
+
+// Highlights the whole source before splitting it, so a line inside a multi-line string or
+// comment keeps that scope. Only the first `lineLimit` lines are highlighted: a streamed source
+// grows on every delta while the preview shows a bounded prefix.
+export function highlightedSourceLines(
+ code: string,
+ languageName: string,
+ lineLimit = Infinity
+): string[] {
+ if (code === '' || lineLimit <= 0) return []
+ const sourceLines = code.split('\n')
+ const truncated = sourceLines.length > lineLimit
+ const shownLines = truncated ? sourceLines.slice(0, lineLimit) : sourceLines
+ const source = truncated ? shownLines.join('\n') : code
+ if (shownLines.length > MAX_HIGHLIGHTED_LINES || source.length > MAX_HIGHLIGHTED_CHARS) {
+ const plain = shownLines.map((line) =>
+ escapeHtml(
+ line.length > MAX_PLAIN_LINE_CHARS ? line.slice(0, MAX_PLAIN_LINE_CHARS) + '…' : line
+ )
+ )
+ if (!truncated && code.endsWith('\n')) plain.pop()
+ return plain
+ }
+ const highlighted = hljs.highlight(source, { language: languageName }).value
+ return splitHighlightedLines(highlighted, !truncated && code.endsWith('\n'))
+}
+
+// Returns one entry per source line, closing the spans still open at each line end and
+// reopening them on the next line so every entry is balanced HTML.
+export function splitHighlightedLines(highlighted: string, hasFinalNewline: boolean): string[] {
+ const result: string[] = []
+ const openTags: string[] = []
+ let line = ''
+
+ for (const token of highlighted.split(/(<[^>]+>|\n)/)) {
+ if (token === '\n') {
+ result.push(line + ''.repeat(openTags.length))
+ line = openTags.join('')
+ } else {
+ line += token
+ if (token.startsWith('') openTags.pop()
+ }
+ }
+
+ if (!hasFinalNewline) result.push(line)
+ return result
+}
diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts
new file mode 100644
index 0000000000..ddf2464136
--- /dev/null
+++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts
@@ -0,0 +1,43 @@
+import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages'
+import graphql from 'svelte-highlight/languages/graphql'
+import javascript from 'svelte-highlight/languages/javascript'
+import php from 'svelte-highlight/languages/php'
+import python from 'svelte-highlight/languages/python'
+import powershell from 'svelte-highlight/languages/powershell'
+import r from 'svelte-highlight/languages/r'
+import ruby from 'svelte-highlight/languages/ruby'
+import rust from 'svelte-highlight/languages/rust'
+import shell from 'svelte-highlight/languages/shell'
+import sql from 'svelte-highlight/languages/sql'
+import typescript from 'svelte-highlight/languages/typescript'
+import yaml from 'svelte-highlight/languages/yaml'
+
+// Keyed by the editor language `scriptLangToEditorLang` returns, not the script language.
+// for related places search: ADD_NEW_LANG
+const LANGUAGE_BY_MONACO: Record> = {
+ typescript,
+ javascript,
+ python,
+ json,
+ yaml,
+ sql,
+ shell,
+ powershell,
+ php,
+ rust,
+ graphql,
+ csharp,
+ nu: python,
+ java,
+ r,
+ go,
+ ruby,
+ text: plaintext,
+ plaintext
+}
+
+export const TOOL_CODE_DIFF_LANGUAGES = Object.keys(LANGUAGE_BY_MONACO)
+
+export function toolCodeDiffLanguage(language: string): LanguageType {
+ return LANGUAGE_BY_MONACO[language] ?? plaintext
+}
diff --git a/frontend/src/routes/test_dev/diff-comparison/+page.svelte b/frontend/src/routes/test_dev/diff-comparison/+page.svelte
new file mode 100644
index 0000000000..c473dbcb64
--- /dev/null
+++ b/frontend/src/routes/test_dev/diff-comparison/+page.svelte
@@ -0,0 +1,139 @@
+
+
+
+ Diff comparison
+
+
+
+
+ {#each cases as item}
+
+ {/each}
+
+
+
+
+ Monaco
+
+ {#key activeCase.id}
+
+ {/key}
+
+
+
+
+ Native
+
+ {#key activeCase.id}
+
+ {/key}
+
+
+
+