mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
feat(desktop): PR-style file diffs in chat
Render write_file/edit_file/patch as a reviewable diff instead of raw result JSON, closer to a Cursor/T3 per-edit review. - Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise, drop the +/- gutter, color by line with a 2px gutter accent, full-bleed to the card, transparent context lines, compact scroll height. - Header shows filename + language icon + +N/-N stats; full path moves to a hover tooltip (no Edited verb, no ms). - Treat the three file-edit tools uniformly (isFileEditTool); read diff from inline_diff or patch's diff field; suppress raw-arg detail. - Reusable FileTypeIcon primitive sharing the code-block icon mapping (codiconForFilename), codicon fallback. - Per-row scaffolding fade (not the group wrapper, which trapped child opacity); expanded edits stay full, collapsed fade; keyboard-only focus lift. Hide diff-less rehydrated creates that read as dupes.
This commit is contained in:
parent
fb3d31ba8b
commit
a61baa9615
7 changed files with 451 additions and 50 deletions
|
|
@ -15,11 +15,17 @@ interface DiffLineKind {
|
|||
|
||||
const DIFF_LINE_KINDS: DiffLineKind[] = [
|
||||
{
|
||||
className: 'text-emerald-700 dark:text-emerald-300',
|
||||
className: 'border-emerald-500 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200',
|
||||
match: line => line.startsWith('+') && !line.startsWith('+++')
|
||||
},
|
||||
{ className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') },
|
||||
{ className: 'text-sky-700 dark:text-sky-300', match: line => 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))
|
||||
|
|
@ -30,25 +36,127 @@ function classifyLine(line: string): string | undefined {
|
|||
return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className
|
||||
}
|
||||
|
||||
// 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.
|
||||
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(' ')) {
|
||||
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(
|
||||
'mt-1 mb-1.5 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
|
||||
'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}
|
||||
>
|
||||
{text.split('\n').map((line, index) => (
|
||||
<span className={cn('block min-w-max whitespace-pre', classifyLine(line))} key={`${index}-${line}`}>
|
||||
{line || ' '}
|
||||
{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.
|
||||
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')
|
||||
}
|
||||
|
||||
interface FileDiffPanelProps {
|
||||
diff: string
|
||||
}
|
||||
|
||||
export function FileDiffPanel({ diff }: FileDiffPanelProps) {
|
||||
const display = React.useMemo(() => stripDiffFileHeaders(diff), [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} />
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue