mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
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:
parent
c6fbd5a104
commit
ac128af1ce
4 changed files with 254 additions and 113 deletions
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue