From ac128af1cec30238f21376273ce4f96088a800bd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 05:10:23 -0500 Subject: [PATCH] feat(desktop): syntax-highlight inline diffs via Shiki MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/assistant-ui/tool-fallback.tsx | 2 +- .../src/components/chat/diff-lines.tsx | 253 +++++++++++------- apps/desktop/src/lib/markdown-code.ts | 97 ++++++- apps/desktop/src/styles.css | 15 ++ 4 files changed, 254 insertions(+), 113 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 900d4767f7b..8d6a7eb157c 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -439,7 +439,7 @@ function ToolEntry({ part }: ToolEntryProps) { )} - {view.inlineDiff && } + {view.inlineDiff && } {showDetail && toolViewMode !== 'technical' && (view.status === 'error' ? ( diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index a8a1bfc314b..fefc8024475 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -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 = { + add: 'border-emerald-500 bg-emerald-500/12', + context: 'border-transparent', + remove: 'border-rose-500 bg-rose-500/12' +} + +const DIFF_KIND_TEXT: Record = { + 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, 'children'> { - text: string -} - -export function DiffLines({ className, text, ...props }: DiffLinesProps) { - const lines = React.useMemo(() => toDisplayLines(text), [text]) - - return ( -
-      {lines.map((line, index) => (
-        
-          {line.text || ' '}
-        
-      ))}
-    
- ) -} - // 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) => ( + + {line.text || ' '} + + ))} + + ) +} + +// 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) ?? +} + +interface DiffLinesProps extends Omit, 'children'> { + text: string +} + +export function DiffLines({ className, text, ...props }: DiffLinesProps) { + const lines = React.useMemo(() => parseDiff(text), [text]) + + return ( +
+      
+    
+ ) +} + 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 + return ( +
+ {canHighlight ? : } +
+ ) } diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 6c34b1fcac3..3d9f3e5e1b6 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -145,19 +145,100 @@ const LANGUAGE_BY_EXTENSION: Record = { // `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 = { + 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() diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index a56b87186df..4ddc226b305 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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