feat(ai-chat): show native script edit diffs (#11278)

* feat(ai-chat): show native script edit diffs

* fix: render streamed edit diffs

* fix: render in-editor script edit diffs

* style: soften diff context separators

* fix: render full script update diffs

* fix: support empty script diffs

* fix: bound streamed tool diffs

* fix: preserve trailing diff context

* fix: defer collapsed tool diffs

* fix: retain full-code streaming previews

* fix: preserve created script diffs

* fix: disambiguate collapsed diff keys

* fix: render final newline diffs

* fix: preserve multiline diff highlighting

* fix: highlight diff sides as complete sources

* fix: bound completed tool diffs

* fix: diff large completed tool edits

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: bound tool diff rendering

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: bound tool diff highlighting and expand omitted rows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: reveal omitted diff rows in chunks and bound long-line highlighting

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: collapse written scripts, skip empty diff bodies and fit line numbers

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep whole-file tool diffs collapsed while running

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: classify whole-file tool calls by argument shape

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: reopen failed diff cards and keep empty streamed replacements

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-09-23 15:44:40 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent fa0fc24269
commit 1de54eeea4
13 changed files with 1443 additions and 14 deletions
@@ -0,0 +1,170 @@
<script lang="ts">
import type { ToolCodeDiff } from './shared'
import {
toolDiffLines,
visibleToolDiffRows,
type CharacterRange,
type ToolDiffLine
} from './toolCodeDiff'
import { toolCodeDiffLanguage } from './toolCodeDiffLanguage'
import { highlightedSourceLines } from './toolCodeDiffHighlight'
import hljs from 'highlight.js/lib/core'
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
import { Button } from '$lib/components/common'
interface Props {
diff: ToolCodeDiff
streaming?: boolean
diffLines?: ToolDiffLine[]
}
let { diff, streaming = false, diffLines }: Props = $props()
let expansions = $state<Map<string, number>>(new Map())
const diffRows = $derived(diffLines ?? toolDiffLines(diff, streaming))
const visibleRows = $derived(visibleToolDiffRows(diffRows, expansions))
const language = $derived(toolCodeDiffLanguage(diff.lang))
const lastLines = $derived.by(() => {
let old = 0
let current = 0
for (const row of diffRows) {
if (row.oldLine) old = Math.max(old, row.oldLine)
if (row.newLine) current = Math.max(current, row.newLine)
}
return { old, current }
})
// Both number columns fit the longest line number, so it never runs into the +/- column.
const lineNumberWidth = $derived(
`max(0.875rem, ${String(Math.max(lastLines.old, lastLines.current)).length}ch)`
)
const highlighted = $derived.by(() => {
if (!hljs.getLanguage(language.name)) hljs.registerLanguage(language.name, language.register)
return {
original: highlightedSourceLines(diff.before, language.name, lastLines.old),
modified: highlightedSourceLines(diff.after, language.name, lastLines.current)
}
})
function highlightedLine(row: ToolDiffLine): string {
const source = row.kind === 'removed' ? highlighted.original : highlighted.modified
const lineNumber = row.kind === 'removed' ? row.oldLine : row.newLine
return lineNumber === undefined ? '' : (source[lineNumber - 1] ?? '')
}
function expand(key: string): void {
expansions = new Map(expansions).set(key, (expansions.get(key) ?? 0) + 1)
}
// Tab stops are measured from the line start, so a range's width is its end column minus its
// start column, never the width of the range text on its own. Counts UTF-16 units, the unit
// of Monaco's ranges and of `slice`, so an emoji ahead of a change does not shift it.
function displayColumns(content: string): number {
let columns = 0
for (let index = 0; index < content.length; index++) {
columns += content[index] === '\t' ? 4 - (columns % 4) : 1
}
return columns
}
function rangeStyle(content: string, range: CharacterRange): string {
const left = displayColumns(content.slice(0, range.start))
const width = range.extendsToEnd
? 'calc(100% - ' + left + 'ch)'
: displayColumns(content.slice(0, range.start + range.length)) - left + 'ch'
return 'left: ' + left + 'ch; width: ' + width
}
</script>
<HighlightTheme />
<div
class="tool-code-diff max-h-[400px] overflow-auto bg-surface-tertiary text-xs leading-[18px] text-primary"
style="--line-number: {lineNumberWidth}"
>
{#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'}
<Button
unifiedSize="2xs"
variant="subtle"
btnClasses="grid min-h-7 w-full grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)] items-center gap-0 rounded-none bg-surface-secondary px-0 py-1 text-left text-xs font-normal leading-[18px] text-hint hover:bg-surface-hover"
onclick={() => expand(row.key)}
>
<span class="text-right">...</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem]">
<span></span><span class="text-right">...</span><span>...</span>
</span>
<span>{row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'}</span>
</Button>
{:else}
<div
class="diff-line-{row.kind} grid grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)]"
>
<span class="text-right text-hint">{row.oldLine ?? ''}</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem] text-hint">
<span></span><span class="text-right">{row.newLine ?? ''}</span><span
>{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''}</span
>
</span>
<span
class="relative min-w-0 whitespace-pre {row.kind === 'added'
? 'bg-green-500/20'
: row.kind === 'removed'
? 'bg-red-500/20'
: ''}"
>
{#each row.changedRanges ?? [] as range}
<span
class="pointer-events-none absolute top-0 z-0 h-[18px] {row.kind === 'added'
? 'bg-green-500/25'
: 'bg-red-500/20'}"
style={rangeStyle(row.content, range)}
></span>
{/each}
<span class="relative z-[1]">{@html highlightedLine(row) || '&nbsp;'}</span>
</span>
</div>
{/if}
{/each}
</div>
<style>
.tool-code-diff {
font-family: Menlo, Monaco, 'Courier New', monospace;
/* Must match the tab stops `displayColumns` uses to place intraline highlights. */
tab-size: 4;
}
.diff-line-added > span:nth-child(-n + 2),
.diff-line-removed > span:nth-child(-n + 2) {
@apply text-hint;
}
.tool-code-diff :global(.hljs-keyword),
.tool-code-diff :global(.hljs-literal),
.tool-code-diff :global(.hljs-built_in) {
@apply text-blue-700;
}
.tool-code-diff :global(.hljs-string) {
@apply text-red-700;
}
.tool-code-diff :global(.hljs-title.class_),
.tool-code-diff :global(.hljs-type) {
@apply text-cyan-800;
}
.tool-code-diff :global(.hljs-title.function_) {
@apply text-primary;
}
.tool-code-diff :global(.hljs-attr),
.tool-code-diff :global(.hljs-subst) {
@apply text-primary;
}
.tool-code-diff :global(.hljs-number) {
@apply text-green-700;
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import type { ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import ToolCodeDiffView from './ToolCodeDiffView.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import {
argumentsCarryWholeFile,
diffLineCounts,
toolCodeDiff,
toolDiffLineCounts,
toolDiffLines
} from './toolCodeDiff'
interface Props {
message: ToolDisplayMessage
}
let { message }: Props = $props()
const diff = $derived(toolCodeDiff(message))
const isStreaming = $derived(Boolean(message.isStreamingArguments))
const hasDiff = $derived(Boolean(diff && (diff.before !== '' || diff.after !== '')))
// The card body is a bordered box: opened with nothing in it, it draws an empty line.
const hasBody = $derived(hasDiff || Boolean(message.error))
// Keyed by call id: a bare flag would carry the expansion onto the next message that
// reuses this instance. An active or failed call opens by itself, and remains open
// when it settles so the new diff does not disappear under the user. A call whose arguments
// are a whole file (`write_script`, full-code `edit_code`) opens only on failure. The key
// also changes on failure, so a collapse made while running cannot hide the error.
const toggleKey = $derived(`${message.tool_call_id}:${message.error ? 'error' : ''}`)
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
const opensByDefault = $derived(
Boolean(
message.error ||
(hasDiff &&
!argumentsCarryWholeFile(message) &&
(message.isLoading || message.isQueued || message.isStreamingArguments))
)
)
$effect(() => {
if (opensByDefault && toggled?.id !== toggleKey) {
toggled = { id: toggleKey, open: true }
}
})
const expanded = $derived(
hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)
)
const lines = $derived(diff && expanded ? toolDiffLines(diff, isStreaming) : undefined)
const counts = $derived(
diff && expanded
? isStreaming
? diffLineCounts(diff, true)
: toolDiffLineCounts(lines ?? [])
: undefined
)
const isRunning = $derived(Boolean(message.isLoading && !message.needsConfirmation))
const showPreviewChip = $derived(
Boolean(message.previewCard && !message.isLoading && !message.error)
)
</script>
{#snippet headerRight()}
<div class="flex items-center gap-2 shrink-0">
{#if counts && (counts.added > 0 || counts.removed > 0)}
<span class="font-mono text-2xs">
<span class="text-green-600 dark:text-green-400">+{counts.added}</span>
<span class="text-red-600 dark:text-red-400">−{counts.removed}</span>
</span>
{/if}
{#if showPreviewChip && message.previewCard}
<ToolPreviewCard card={message.previewCard} />
{/if}
</div>
{/snippet}
<ChatCollapsibleCard
label={message.content}
{expanded}
onToggle={() => (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}
<ToolCodeDiffView {diff} diffLines={lines} streaming={isStreaming} />
{/if}
{#if message.error}
<div
class="px-3 py-2 text-2xs text-red-600 dark:text-red-400 whitespace-pre-wrap break-words {hasDiff
? 'border-t border-border-light'
: ''}"
>
{message.error}
</div>
{/if}
</ChatCollapsibleCard>
@@ -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 @@
</div>
{:else if isRunCard}
<RunScriptCard {message} />
{:else if isDiffCard}
<ToolDiffCard {message} />
{:else if planState}
<!-- Same lean shape as a tool call below: a header row that collapses into the
transcript, with everything else in one box under it. -->
@@ -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<any>('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', () => {
@@ -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<T, A>(
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<string> {
const { workspace } = ctx
startDraftWrite(ctx, type, path)
@@ -5097,7 +5105,8 @@ async function writeDraft<T, A>(
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<NewScript, ScriptDraftArgs> = {
}
}
function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise<string> {
return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override })
function writeScriptDraft(
args: ScriptDraftArgs,
ctx: WriteDraftCtx,
codeDiff?: ToolCodeDiff
): Promise<string> {
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) }
)
}
@@ -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'
}
})
})
})
@@ -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<ScriptChatHelpers> = {
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<ScriptChatHelpers> = {
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) {
@@ -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. */
@@ -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>): 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(
'<span class="hljs-keyword">const</span> s = <span class="hljs-string">`a</span>'
)
expect(lines[1]).toMatch(/^<span class="hljs-string">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 &lt; 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()
}
})
})
@@ -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<string, (params: unknown) => 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<string, unknown>)[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<number, CharacterRange[]>()
const addedRanges = new Map<number, CharacterRange[]>()
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<string, number>
): 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<number, CharacterRange[]>
): 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<number, CharacterRange[]>,
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 } : {}) }
])
}
}
}
@@ -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 + '</span>'.repeat(openTags.length))
line = openTags.join('')
} else {
line += token
if (token.startsWith('<span')) openTags.push(token)
else if (token === '</span>') openTags.pop()
}
}
if (!hasFinalNewline) result.push(line)
return result
}
@@ -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<string, LanguageType<string>> = {
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<string> {
return LANGUAGE_BY_MONACO[language] ?? plaintext
}
@@ -0,0 +1,139 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import DiffEditor from '$lib/components/DiffEditor.svelte'
import ToolCodeDiffView from '$lib/components/copilot/chat/ToolCodeDiffView.svelte'
import type { ToolCodeDiff } from '$lib/components/copilot/chat/shared'
type ComparisonCase = {
id: string
label: string
diff: ToolCodeDiff
}
const stableLines = Array.from(
{ length: 14 },
(_, index) => ` const stableValue${index + 1} = getStableValue(${index + 1})`
)
const cases: ComparisonCase[] = [
{
id: 'typescript-replacement',
label: 'TypeScript replacement',
diff: {
lang: 'typescript',
before: `export function greeting(name: string) {
return \`Hello, \${name}\`
}`,
after: `export function greeting(name: string) {
const message = \`Hello, \${name}!\`
return message
}`
}
},
{
id: 'long-unchanged-sections',
label: 'Long unchanged sections',
diff: {
lang: 'typescript',
before: [
'export function buildSummary() {',
...stableLines,
'\treturn formatSummary("draft")',
'}'
].join('\n'),
after: [
'export function buildSummary() {',
...stableLines,
'\treturn formatSummary("published")',
'}'
].join('\n')
}
},
{
id: 'json-structure',
label: 'JSON structure',
diff: {
lang: 'json',
before: `{
"name": "daily-report",
"schedule": "0 9 * * 1-5",
"enabled": false
}`,
after: `{
"name": "daily-report",
"schedule": "0 9 * * 1-5",
"timezone": "Europe/Paris",
"enabled": true
}`
}
},
{
id: 'python-add-remove',
label: 'Python additions and removals',
diff: {
lang: 'python',
before: `def notify(users):
for user in users:
send_email(user, "Weekly update")
return len(users)`,
after: `def notify(users):
active_users = [user for user in users if user.active]
for user in active_users:
send_email(user, "Your weekly update")
return len(active_users)`
}
}
]
let activeCaseId = $state(cases[0].id)
let activeCase = $derived(cases.find((item) => item.id === activeCaseId) ?? cases[0])
</script>
<svelte:head>
<title>Diff comparison</title>
</svelte:head>
<main class="mx-auto max-w-[1440px] p-6">
<div class="mb-5 flex flex-wrap gap-2">
{#each cases as item}
<Button
unifiedSize="xs"
variant="subtle"
selected={activeCaseId === item.id}
aria-pressed={activeCaseId === item.id}
onclick={() => (activeCaseId = item.id)}>{item.label}</Button
>
{/each}
</div>
<div class="grid gap-5 lg:grid-cols-2">
<section class="min-w-0">
<h1 class="mb-2 text-sm font-semibold">Monaco</h1>
<div class="h-[400px] overflow-hidden border border-slate-200 bg-white">
{#key activeCase.id}
<DiffEditor
className="h-full"
defaultLang={activeCase.diff.lang}
defaultModifiedLang={activeCase.diff.lang}
defaultOriginal={activeCase.diff.before}
defaultModified={activeCase.diff.after}
inlineDiff
open
readOnly
/>
{/key}
</div>
</section>
<section class="min-w-0">
<h2 class="mb-2 text-sm font-semibold">Native</h2>
<div class="h-[400px] overflow-auto border border-slate-200 bg-white">
{#key activeCase.id}
<ToolCodeDiffView diff={activeCase.diff} />
{/key}
</div>
</section>
</div>
</main>