feat(desktop): syntax-highlight inline diffs via Shiki

Unify the diff renderer onto the same Shiki path as code blocks: highlight
the marker-stripped change content in the file's language, then a per-line
transformer layers the add/remove tint + gutter accent on top. Falls back
to the plain color-only renderer when the language is unknown, over budget,
or while Shiki loads.

- shikiLanguageForFilename(): extension → bundled-language id (shared
  filename-token helper with codiconForFilename).
- code display:grid so full-width line tints don't double with newline
  nodes; theme surface stripped so context lines stay transparent.
This commit is contained in:
Brooklyn Nicholson 2026-06-22 05:10:23 -05:00
parent c6fbd5a104
commit ac128af1ce
4 changed files with 254 additions and 113 deletions

View file

@ -439,7 +439,7 @@ function ToolEntry({ part }: ToolEntryProps) {
<SearchResultsList hits={view.searchHits} />
</div>
)}
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} />}
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />}
{showDetail &&
toolViewMode !== 'technical' &&
(view.status === 'error' ? (

View file

@ -1,122 +1,82 @@
import * as React from 'react'
'use client'
import type { ReactNode } from 'react'
import * as React from 'react'
import { useShikiHighlighter } from 'react-shiki'
import type { ShikiTransformer } from 'shiki'
import { exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
/**
* Per-line classed renderer for unified diffs. Lives outside `CodeCard` so
* tool-result panels (already nested inside a tool card) don't double-shell;
* for markdown ` ```diff ` fences the standard `CodeCard` + Shiki path runs
* instead and gives equivalent coloring.
* Renders a unified diff for a tool's file edit. Two paths share one parse:
* - `SyntaxDiff` highlights the change *content* in the file's language via
* Shiki, then a per-line transformer paints the add/remove tint on top.
* - `DiffLines` is the color-only fallback (no language, over budget, or while
* Shiki loads).
* Both drop git file-headers + `@@` hunk noise and the `+/-` gutter so changes
* read by color + a 2px gutter accent, the way Cursor does.
*/
interface DiffLineKind {
className?: string
match: (line: string) => boolean
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
type DiffKind = 'add' | 'context' | 'remove'
interface DiffLine {
kind: DiffKind
text: string
}
const DIFF_LINE_KINDS: DiffLineKind[] = [
{
className: 'border-emerald-500 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200',
match: line => line.startsWith('+') && !line.startsWith('+++')
},
{
className: 'border-rose-500 bg-rose-500/12 text-rose-800 dark:text-rose-200',
match: line => line.startsWith('-') && !line.startsWith('---')
},
{
className: 'text-sky-700 dark:text-sky-300',
match: line => line.startsWith('@@')
},
{
className: 'text-muted-foreground/70',
match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
// Tint + 2px gutter accent per change kind. Text color is included for the
// plain renderer; the Shiki path omits it so syntax colors win, layering only
// the background + border.
const DIFF_KIND_TINT: Record<DiffKind, string> = {
add: 'border-emerald-500 bg-emerald-500/12',
context: 'border-transparent',
remove: 'border-rose-500 bg-rose-500/12'
}
const DIFF_KIND_TEXT: Record<DiffKind, string> = {
add: 'text-emerald-800 dark:text-emerald-200',
context: '',
remove: 'text-rose-800 dark:text-rose-200'
}
const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px'
// Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the
// card edges (rounded corners clip via the card's overflow); compact height
// with internal scroll like a code block.
const DIFF_BOX_CLASS =
'-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-contain font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)'
function diffKind(line: string): DiffKind {
if (line.startsWith('+') && !line.startsWith('+++')) {
return 'add'
}
]
function classifyLine(line: string): string | undefined {
return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className
if (line.startsWith('-') && !line.startsWith('---')) {
return 'remove'
}
return 'context'
}
// Drop the leading +/-/space gutter character so changes read by color alone
// (like Cursor), keeping the rest of the indentation intact. Hunk headers
// (`@@`) and any stray file headers are left untouched.
// Drop the leading +/-/space gutter so changes read by color alone, keeping the
// rest of the indentation intact.
function stripDiffMarker(line: string): string {
if (line.startsWith('@@')) {
return line
}
if ((line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) {
return line.slice(1)
}
if (line.startsWith(' ')) {
if (diffKind(line) !== 'context' || line.startsWith(' ')) {
return line.slice(1)
}
return line
}
interface DisplayLine {
className?: string
text: string
}
// Build the rendered line list: drop `@@ … @@` hunk headers (git noise in a
// GUI) and the +/- gutter, but keep a blank separator between hunks so
// multi-hunk diffs don't visually merge.
function toDisplayLines(text: string): DisplayLine[] {
const out: DisplayLine[] = []
let emitted = false
for (const line of text.split('\n')) {
if (line.startsWith('@@')) {
if (emitted) {
out.push({ text: '' })
}
continue
}
out.push({ className: classifyLine(line), text: stripDiffMarker(line) })
emitted = true
}
return out
}
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
text: string
}
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
const lines = React.useMemo(() => toDisplayLines(text), [text])
return (
<pre
className={cn(
'max-h-[12rem] max-w-full min-w-0 overflow-auto overscroll-contain px-0 py-1 font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)',
className
)}
data-slot="diff-lines"
{...props}
>
{lines.map((line, index) => (
<span
className={cn('block min-w-max border-l-2 border-transparent whitespace-pre px-2.5 py-px', line.className)}
key={`${index}-${line.text}`}
>
{line.text || ' '}
</span>
))}
</pre>
)
}
// Git-style unified diffs arrive with a file-header preamble — `diff --git`,
// `index …`, `--- a/path`, `+++ b/path`, and Hermes' own `a/path → b/path`
// arrow line. That preamble just repeats the path (which the tool row already
// shows) and reads especially badly for absolute paths (`a//Users/…`). Strip
// the leading header zone up to the first hunk so the panel shows only hunks +
// changes, the way Cursor does.
// the leading header zone up to the first hunk.
const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file']
function isArrowHeaderLine(line: string): boolean {
@ -147,16 +107,101 @@ export function stripDiffFileHeaders(diff: string): string {
return lines.slice(start).join('\n')
}
// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank
// separator kept between hunks), markers stripped, kind recorded.
function parseDiff(diff: string): DiffLine[] {
const out: DiffLine[] = []
let emitted = false
for (const line of stripDiffFileHeaders(diff).split('\n')) {
if (line.startsWith('@@')) {
if (emitted) {
out.push({ kind: 'context', text: '' })
}
continue
}
out.push({ kind: diffKind(line), text: stripDiffMarker(line) })
emitted = true
}
return out
}
function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) {
return (
<>
{lines.map((line, index) => (
<span
className={cn(DIFF_LINE_BASE, DIFF_KIND_TINT[line.kind], !syntax && DIFF_KIND_TEXT[line.kind])}
key={`${index}-${line.text}`}
>
{line.text || ' '}
</span>
))}
</>
)
}
// Shiki transformer: tag each `.line` with the diff tint for its kind, so the
// syntax-highlighted output keeps add/remove backgrounds + the gutter accent.
function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer {
return {
line(node, line) {
const kind = kinds[line - 1] ?? 'context'
const existing = Array.isArray(node.properties.className)
? (node.properties.className as string[])
: node.properties.className
? [String(node.properties.className)]
: []
node.properties.className = [...existing, DIFF_LINE_BASE, DIFF_KIND_TINT[kind]]
}
}
}
function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) {
const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines])
const transformers = React.useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines])
const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, {
defaultColor: 'light-dark()',
transformers
})
// Until Shiki resolves, show the plain colored diff so there's no flash.
return (highlighted as ReactNode) ?? <DiffBody lines={lines} />
}
interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
text: string
}
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
const lines = React.useMemo(() => parseDiff(text), [text])
return (
<pre className={cn(DIFF_BOX_CLASS, className)} data-slot="diff-lines" {...props}>
<DiffBody lines={lines} />
</pre>
)
}
interface FileDiffPanelProps {
diff: string
path?: string
}
export function FileDiffPanel({ diff }: FileDiffPanelProps) {
const display = React.useMemo(() => stripDiffFileHeaders(diff), [diff])
export function FileDiffPanel({ diff, path }: FileDiffPanelProps) {
const lines = React.useMemo(() => parseDiff(diff), [diff])
const language = shikiLanguageForFilename(path)
const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff)
// Bleed out of the tool-card body's `p-1.5` so changed-line tints/borders run
// flush to the card edges (rounded corners clip via the card's overflow).
// `max-w-none` lifts the base `max-w-full` cap that would otherwise stop the
// negative margins from widening the block.
return <DiffLines className="-mx-1.5 -mb-1.5 max-w-none" data-slot="file-diff-panel" text={display} />
return (
<div className={DIFF_BOX_CLASS} data-slot="file-diff-panel">
{canHighlight ? <SyntaxDiff language={language} lines={lines} /> : <DiffBody lines={lines} />}
</div>
)
}

View file

@ -145,19 +145,100 @@ const LANGUAGE_BY_EXTENSION: Record<string, string> = {
// `Dockerfile`), reusing the language→codicon map so file-edit rows and code
// blocks share one visual vocabulary. Unknown / generic code files get `code`.
export function codiconForFilename(path: string | undefined): string {
const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || ''
if (!base) {
return 'code'
}
const dot = base.lastIndexOf('.')
const token = dot > 0 ? base.slice(dot + 1) : base
const token = filenameExtToken(path)
const language = LANGUAGE_BY_EXTENSION[token] || token
return codiconForLanguage(language)
}
// Last path segment's extension (or the bare lowercased name for `Dockerfile`,
// `Makefile`, …). Shared by the icon and Shiki-language resolvers.
function filenameExtToken(path: string | undefined): string {
const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || ''
const dot = base.lastIndexOf('.')
return dot > 0 ? base.slice(dot + 1) : base
}
// File extension → Shiki bundled-language id, for syntax-highlighting diffs in
// the editing tool's own language. Unknown extensions return '' so callers fall
// back to the plain color-only diff renderer.
const SHIKI_LANGUAGE_BY_EXTENSION: Record<string, string> = {
astro: 'astro',
bash: 'bash',
c: 'c',
cc: 'cpp',
cjs: 'javascript',
clj: 'clojure',
cpp: 'cpp',
cs: 'csharp',
css: 'css',
cxx: 'cpp',
dart: 'dart',
dockerfile: 'docker',
ex: 'elixir',
exs: 'elixir',
fish: 'fish',
go: 'go',
gql: 'graphql',
graphql: 'graphql',
h: 'c',
hpp: 'cpp',
hs: 'haskell',
htm: 'html',
html: 'html',
ini: 'ini',
java: 'java',
jl: 'julia',
js: 'javascript',
json: 'json',
json5: 'json5',
jsonc: 'jsonc',
jsx: 'jsx',
kt: 'kotlin',
kts: 'kotlin',
less: 'less',
lua: 'lua',
makefile: 'make',
markdown: 'markdown',
md: 'markdown',
mdx: 'mdx',
mjs: 'javascript',
ml: 'ocaml',
mts: 'typescript',
nix: 'nix',
php: 'php',
pl: 'perl',
proto: 'proto',
ps1: 'powershell',
py: 'python',
pyi: 'python',
r: 'r',
rb: 'ruby',
rs: 'rust',
sass: 'sass',
scala: 'scala',
scss: 'scss',
sh: 'bash',
sql: 'sql',
svelte: 'svelte',
swift: 'swift',
tf: 'terraform',
toml: 'toml',
ts: 'typescript',
tsx: 'tsx',
vue: 'vue',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
zig: 'zig',
zsh: 'bash'
}
export function shikiLanguageForFilename(path: string | undefined): string {
return SHIKI_LANGUAGE_BY_EXTENSION[filenameExtToken(path)] || ''
}
function proseLineCount(body: string): number {
return body.split('\n').filter(line => {
const trimmed = line.trim()

View file

@ -1238,6 +1238,21 @@ canvas {
opacity: 1;
}
/* Syntax-highlighted inline diff (Shiki): strip the theme's own surface +
default margins so context lines stay transparent and each changed line owns
its tint. `display: grid` on the code puts one `.line` per row and drops the
whitespace-only `\n` nodes between them without it, full-width block lines
double up with the literal newlines (phantom blank rows). */
[data-slot='file-diff-panel'] .shiki,
[data-slot='file-diff-panel'] .shiki code {
margin: 0;
background: transparent !important;
}
[data-slot='file-diff-panel'] .shiki code {
display: grid;
}
/* File edits (write_file / edit_file / patch) are the deliverable, not
scaffolding the diff is what the user reviews, like a PR. An *expanded*
edit stays at full strength; collapsed it fades like any other row. The