hermes-agent/apps/desktop/src/components/chat/diff-lines.tsx
Brooklyn Nicholson 61c266b0dc style(desktop): soften dark-mode syntax highlighting
Share one SHIKI_THEME (github-dark-dimmed) across code blocks and inline
diffs so they can't drift, and pull token saturation/brightness back via a
`.shiki` dark-mode filter. The dimmed theme alone only changes the
background — which both surfaces strip — so the bright foregrounds needed
the filter to actually calm down.
2026-06-22 05:16:18 -05:00

205 lines
6.4 KiB
TypeScript

'use client'
import type { ReactNode } from 'react'
import * as React from 'react'
import { useShikiHighlighter } from 'react-shiki'
import type { ShikiTransformer } from 'shiki'
import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter'
import { shikiLanguageForFilename } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
/**
* 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.
*/
type DiffKind = 'add' | 'context' | 'remove'
interface DiffLine {
kind: DiffKind
text: string
}
// 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'
}
if (line.startsWith('-') && !line.startsWith('---')) {
return 'remove'
}
return 'context'
}
// Drop the leading +/-/space gutter so changes read by color alone, keeping the
// rest of the indentation intact.
function stripDiffMarker(line: string): string {
if (diffKind(line) !== 'context' || line.startsWith(' ')) {
return line.slice(1)
}
return line
}
// 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.
const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file']
function isArrowHeaderLine(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('→') && /^\S.*→\s*\S+$/.test(trimmed) && !/^[+\-@]/.test(trimmed)
}
/** Exported for tests. */
export function stripDiffFileHeaders(diff: string): string {
const lines = diff.split('\n')
let start = 0
for (; start < lines.length; start += 1) {
const line = lines[start]
if (line.startsWith('@@')) {
break
}
if (line.trim() === '' || isArrowHeaderLine(line) || DIFF_HEADER_PREFIXES.some(prefix => line.startsWith(prefix))) {
continue
}
break
}
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, path }: FileDiffPanelProps) {
const lines = React.useMemo(() => parseDiff(diff), [diff])
const language = shikiLanguageForFilename(path)
const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff)
return (
<div className={DIFF_BOX_CLASS} data-slot="file-diff-panel">
{canHighlight ? <SyntaxDiff language={language} lines={lines} /> : <DiffBody lines={lines} />}
</div>
)
}