mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +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
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildToolView, type ToolPart } from './tool-fallback-model'
|
||||
import {
|
||||
buildToolView,
|
||||
countDiffLineStats,
|
||||
inlineDiffFromResult,
|
||||
type ToolPart
|
||||
} from './tool-fallback-model'
|
||||
|
||||
const part = (overrides: Partial<ToolPart>): ToolPart => ({
|
||||
args: {},
|
||||
|
|
@ -64,3 +69,51 @@ describe('buildToolView terminal exit-code status', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView file edit diffs', () => {
|
||||
const patchDiff = '--- a/src/demo.ts\n+++ b/src/demo.ts\n@@ -1 +1 @@\n-old\n+new'
|
||||
|
||||
it('reads inline_diff and diff fields from patch results', () => {
|
||||
expect(inlineDiffFromResult({ inline_diff: patchDiff })).toBe(patchDiff)
|
||||
expect(inlineDiffFromResult({ diff: patchDiff })).toBe(patchDiff)
|
||||
})
|
||||
|
||||
it('suppresses raw patch args when a diff is available', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { context: 'src/demo.ts', mode: 'replace', new_string: 'new', path: 'src/demo.ts' },
|
||||
result: { diff: patchDiff, success: true },
|
||||
toolName: 'patch'
|
||||
}),
|
||||
patchDiff
|
||||
)
|
||||
|
||||
expect(view.title).toBe('demo.ts')
|
||||
expect(view.subtitle).toBe('src/demo.ts')
|
||||
expect(view.detail).toBe('')
|
||||
expect(view.inlineDiff).toBe(patchDiff)
|
||||
})
|
||||
|
||||
it('shows path subtitle instead of patch args JSON while pending', () => {
|
||||
const view = buildToolView(
|
||||
part({
|
||||
args: { context: 'src/demo.ts', mode: 'replace', new_string: 'new', path: 'src/demo.ts' },
|
||||
result: undefined,
|
||||
toolName: 'patch'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
expect(view.title).toBe('demo.ts')
|
||||
expect(view.subtitle).toBe('src/demo.ts')
|
||||
expect(view.detail).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countDiffLineStats', () => {
|
||||
it('counts added and removed lines', () => {
|
||||
expect(
|
||||
countDiffLineStats(`--- a/x\n+++ b/x\n@@\n-old\n+new\n context\n+another`)
|
||||
).toEqual({ added: 2, removed: 1 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -72,6 +72,46 @@ export interface MessageRunningStateSlice {
|
|||
}
|
||||
}
|
||||
|
||||
const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file'])
|
||||
|
||||
export function isFileEditTool(toolName: string): boolean {
|
||||
return FILE_EDIT_TOOL_NAMES.has(toolName)
|
||||
}
|
||||
|
||||
export interface DiffLineStats {
|
||||
added: number
|
||||
removed: number
|
||||
}
|
||||
|
||||
export function countDiffLineStats(diff: string): DiffLineStats {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
added += 1
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
function fileEditPath(args: Record<string, unknown>, result: Record<string, unknown>): string {
|
||||
return (
|
||||
firstStringField(args, ['path', 'file', 'filepath']) ||
|
||||
firstStringField(result, ['path', 'file', 'filepath', 'resolved_path']) ||
|
||||
htmlPathFromInlineDiff(firstStringField(result, ['inline_diff', 'diff']))
|
||||
)
|
||||
}
|
||||
|
||||
function fileEditBasename(path: string): string {
|
||||
const normalized = path.replace(/\\/g, '/').trim()
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
const TOOL_META: Record<string, ToolMeta> = {
|
||||
browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' },
|
||||
browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' },
|
||||
|
|
@ -95,7 +135,7 @@ const TOOL_META: Record<string, ToolMeta> = {
|
|||
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
|
||||
patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' },
|
||||
patch: { done: 'Patched file', pending: 'Patching file', icon: 'edit', tone: 'file' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
|
||||
session_search_recall: {
|
||||
|
|
@ -797,8 +837,8 @@ function toolPreviewTarget(toolName: string, args: Record<string, unknown>, resu
|
|||
return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result)
|
||||
}
|
||||
|
||||
if (toolName === 'write_file' || toolName === 'edit_file') {
|
||||
return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff']))
|
||||
if (isFileEditTool(toolName)) {
|
||||
return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff', 'diff']))
|
||||
}
|
||||
|
||||
return ''
|
||||
|
|
@ -858,9 +898,17 @@ function stripDividerLines(value: string): string {
|
|||
}
|
||||
|
||||
export function inlineDiffFromResult(result: unknown): string {
|
||||
const value = parseMaybeObject(result).inline_diff
|
||||
const record = parseMaybeObject(result)
|
||||
|
||||
return typeof value === 'string' ? stripInlineDiffChrome(value) : ''
|
||||
for (const key of ['inline_diff', 'diff']) {
|
||||
const value = record[key]
|
||||
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return stripInlineDiffChrome(value)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Falls back to a string only when there's something concrete to render —
|
||||
|
|
@ -1047,15 +1095,22 @@ function toolSubtitle(
|
|||
return command ? compactPreview(command, 120) : 'Executed command'
|
||||
}
|
||||
|
||||
if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') {
|
||||
const path =
|
||||
firstStringField(argsRecord, ['path', 'file', 'filepath']) ||
|
||||
htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff']))
|
||||
if (toolName === 'read_file' || isFileEditTool(toolName)) {
|
||||
const isEdit = isFileEditTool(toolName)
|
||||
|
||||
return (
|
||||
path ||
|
||||
(firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord))
|
||||
)
|
||||
const path = isEdit
|
||||
? fileEditPath(argsRecord, resultRecord)
|
||||
: firstStringField(argsRecord, ['path', 'file', 'filepath'])
|
||||
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
|
||||
if (!isEdit) {
|
||||
return fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
return inlineDiffFromResult(resultRecord) ? 'Changed file' : ''
|
||||
}
|
||||
|
||||
if (toolName === 'web_extract') {
|
||||
|
|
@ -1153,8 +1208,22 @@ function toolDetailText(
|
|||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
|
||||
return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord)
|
||||
if (isFileEditTool(part.toolName)) {
|
||||
if (inlineDiffFromResult(part.result)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const summary = firstStringField(resultRecord, ['message', 'summary'])
|
||||
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
|
||||
if (fileEditPath(argsRecord, resultRecord)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
if (part.toolName === 'web_search') {
|
||||
|
|
@ -1253,8 +1322,12 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
|||
}
|
||||
}
|
||||
|
||||
if (part.toolName === 'write_file' || part.toolName === 'edit_file') {
|
||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||
if (isFileEditTool(part.toolName)) {
|
||||
if (view.inlineDiff.trim()) {
|
||||
return { label: copy.file, text: view.inlineDiff }
|
||||
}
|
||||
|
||||
const path = fileEditPath(args, result)
|
||||
|
||||
if (path) {
|
||||
return { label: copy.path, text: path }
|
||||
|
|
@ -1304,6 +1377,14 @@ function dynamicTitle(
|
|||
}
|
||||
}
|
||||
|
||||
if (isFileEditTool(part.toolName)) {
|
||||
const path = fileEditPath(args, result)
|
||||
|
||||
if (path) {
|
||||
return fileEditBasename(path)
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
|
|
@ -1317,7 +1398,12 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
|||
const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle)
|
||||
const titleEnriched = title !== baseTitle
|
||||
const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord)
|
||||
const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code'
|
||||
|
||||
const keepSubtitleWithTitle =
|
||||
part.toolName === 'terminal' ||
|
||||
part.toolName === 'execute_code' ||
|
||||
(isFileEditTool(part.toolName) && Boolean(baseSubtitle.trim()))
|
||||
|
||||
const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle
|
||||
const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord))
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
|||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
import { DiffLines } from '@/components/chat/diff-lines'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
|
|
@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { FileTypeIcon } from '@/components/ui/file-type-icon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
|
|
@ -32,7 +33,9 @@ import { PendingToolApproval } from './tool-approval'
|
|||
import {
|
||||
buildToolView,
|
||||
cleanVisibleText,
|
||||
countDiffLineStats,
|
||||
inlineDiffFromResult,
|
||||
isFileEditTool,
|
||||
isPreviewableTarget,
|
||||
looksRedundant,
|
||||
type SearchResultRow,
|
||||
|
|
@ -133,9 +136,21 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
|||
// Leading glyph for any tool-row header. Status (running/error/warning)
|
||||
// takes precedence; otherwise falls back to the tool's codicon. Returns
|
||||
// null when neither applies so callers can render unconditionally.
|
||||
function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) {
|
||||
function ToolGlyph({
|
||||
copy,
|
||||
filePath,
|
||||
icon,
|
||||
status
|
||||
}: {
|
||||
copy: ToolStatusCopy
|
||||
filePath?: string
|
||||
icon?: string
|
||||
status?: ToolStatus
|
||||
}) {
|
||||
const node = status ? (
|
||||
statusGlyph(status, copy)
|
||||
) : filePath ? (
|
||||
<FileTypeIcon className="text-(--ui-text-tertiary)" path={filePath} size="0.875rem" />
|
||||
) : icon ? (
|
||||
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
) : null
|
||||
|
|
@ -204,8 +219,13 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
const toolViewMode = useStore($toolViewMode)
|
||||
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
|
||||
const dismissed = useStore($toolRowDismissed(disclosureId))
|
||||
const open = useDisclosureOpen(disclosureId)
|
||||
const isPending = messageRunning && part.result === undefined
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
|
||||
const isFileEdit = isFileEditTool(part.toolName)
|
||||
const defaultOpen = Boolean(inlineDiff)
|
||||
const open = useDisclosureOpen(disclosureId, defaultOpen)
|
||||
const canDismiss = !isPending && !embedded
|
||||
// Only animate entries that mount while their message is actively
|
||||
// streaming — historical sessions mount with `messageRunning === false`,
|
||||
|
|
@ -213,9 +233,6 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
// handles its own enter animation, so embedded children skip it.
|
||||
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
|
||||
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
|
||||
const liveDiffs = useStore($toolInlineDiffs)
|
||||
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
|
||||
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
|
||||
|
||||
// Stale parts (no result, but message stopped running) get a synthetic
|
||||
// empty result so buildToolView treats them as completed-no-output.
|
||||
|
|
@ -253,11 +270,12 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail)
|
||||
|
||||
const showDetail =
|
||||
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
||||
(view.status !== 'error' &&
|
||||
Boolean(view.detail) &&
|
||||
!looksRedundant(view.title, view.detail) &&
|
||||
!detailMatchesSubtitle)
|
||||
!view.inlineDiff &&
|
||||
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
||||
(view.status !== 'error' &&
|
||||
Boolean(view.detail) &&
|
||||
!looksRedundant(view.title, view.detail) &&
|
||||
!detailMatchesSubtitle))
|
||||
|
||||
const renderDetailAsCode =
|
||||
view.status !== 'error' &&
|
||||
|
|
@ -283,6 +301,13 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
|
||||
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
||||
|
||||
const diffStats = useMemo(
|
||||
() => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null),
|
||||
[isFileEdit, view.inlineDiff]
|
||||
)
|
||||
|
||||
const showDiffStats = !isPending && Boolean(diffStats && (diffStats.added > 0 || diffStats.removed > 0))
|
||||
|
||||
// The header trailing slot only carries the live duration timer while the
|
||||
// tool is running. The copy control used to live here too, but an
|
||||
// `opacity-0` (yet still clickable) button straddling the caret/duration made
|
||||
|
|
@ -299,7 +324,12 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
<Tip label={statusCopy.dismiss}>
|
||||
<Button
|
||||
aria-label={statusCopy.dismiss}
|
||||
className="size-5 rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:text-(--ui-text-primary) hover:opacity-100 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80"
|
||||
className={cn(
|
||||
'size-5 rounded-md text-(--ui-text-tertiary) transition-opacity hover:text-(--ui-text-primary) hover:opacity-100',
|
||||
open
|
||||
? 'opacity-80'
|
||||
: 'opacity-0 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
dismissToolRow(disclosureId)
|
||||
|
|
@ -317,13 +347,24 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
return null
|
||||
}
|
||||
|
||||
// A completed file edit with no diff to review is a bare, unexpandable row.
|
||||
// This is almost always a `write_file` create after a reload: only `patch`
|
||||
// persists its diff in the tool result, so creates rehydrate diff-less and
|
||||
// read like dead duplicates of the real diff row. Hide them — but keep
|
||||
// in-flight writes (activity) and failures (errors) visible.
|
||||
if (isFileEdit && !isPending && view.status !== 'error' && !view.inlineDiff) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
||||
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
|
||||
)}
|
||||
data-file-edit={isFileEdit && open ? '' : undefined}
|
||||
data-slot="tool-block"
|
||||
data-tool-row=""
|
||||
ref={enterRef}
|
||||
>
|
||||
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
|
||||
|
|
@ -333,8 +374,16 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
open={open}
|
||||
trailing={trailing}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ToolGlyph copy={copy} icon={view.icon} status={leadingStatus(isPending, view.status)} />
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
title={isFileEdit && view.subtitle ? view.subtitle : undefined}
|
||||
>
|
||||
<ToolGlyph
|
||||
copy={copy}
|
||||
filePath={isFileEdit ? view.subtitle : undefined}
|
||||
icon={view.icon}
|
||||
status={leadingStatus(isPending, view.status)}
|
||||
/>
|
||||
<FadeText
|
||||
className={cn(
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
|
|
@ -346,7 +395,17 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
{view.title}
|
||||
</FadeText>
|
||||
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
|
||||
{!isPending && view.durationLabel && (
|
||||
{showDiffStats && diffStats && (
|
||||
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
|
||||
{diffStats.added > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">+{diffStats.added}</span>
|
||||
)}
|
||||
{diffStats.removed > 0 && (
|
||||
<span className="text-rose-600 dark:text-rose-400">−{diffStats.removed}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!isFileEdit && !isPending && view.durationLabel && (
|
||||
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -358,7 +417,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
{copyAction.text && (
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-100 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={copyAction.label}
|
||||
showLabel={false}
|
||||
|
|
@ -380,6 +439,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
<SearchResultsList hits={view.searchHits} />
|
||||
</div>
|
||||
)}
|
||||
{view.inlineDiff && <FileDiffPanel diff={view.inlineDiff} />}
|
||||
{showDetail &&
|
||||
toolViewMode !== 'technical' &&
|
||||
(view.status === 'error' ? (
|
||||
|
|
@ -448,14 +508,21 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{toolViewMode === 'technical' && (
|
||||
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
)}
|
||||
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{rawTechnicalTrace(part.args, part.result)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{open && view.inlineDiff && <DiffLines text={view.inlineDiff} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -488,6 +555,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||
<div
|
||||
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
|
||||
data-slot="tool-block"
|
||||
data-tool-group=""
|
||||
ref={enterRef}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
22
apps/desktop/src/components/ui/file-type-icon.tsx
Normal file
22
apps/desktop/src/components/ui/file-type-icon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { ToolIcon, type ToolIconProps } from '@/components/ui/tool-icon'
|
||||
import { codiconForFilename, codiconForLanguage } from '@/lib/markdown-code'
|
||||
|
||||
export interface FileTypeIconProps extends Omit<ToolIconProps, 'name'> {
|
||||
/** A code-fence language tag (e.g. `ts`, `json`). Used when no `path`. */
|
||||
language?: string
|
||||
/** A file path or bare name; its extension selects the icon. Wins over `language`. */
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon for a file or code language, resolved through the one mapping shared
|
||||
* with code blocks (`codiconForFilename` / `codiconForLanguage`). Renders via
|
||||
* `ToolIcon`, so it uses a filled glyph when one exists and falls back to the
|
||||
* outline codicon font otherwise. Pass a `path` for file rows or a `language`
|
||||
* for fenced code.
|
||||
*/
|
||||
export function FileTypeIcon({ language, path, ...props }: FileTypeIconProps) {
|
||||
const name = path ? codiconForFilename(path) : codiconForLanguage(language)
|
||||
|
||||
return <ToolIcon name={name} {...props} />
|
||||
}
|
||||
|
|
@ -108,6 +108,56 @@ export function codiconForLanguage(language: string | undefined): string {
|
|||
return CODICON_BY_LANGUAGE[sanitizeLanguageTag(language || '')] || 'code'
|
||||
}
|
||||
|
||||
// File extension → language tag, so a filename can resolve to the same icon a
|
||||
// fenced code block of that language would get. Only extensions that map to a
|
||||
// non-generic codicon need an entry; everything else falls through to `code`.
|
||||
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
|
||||
bash: 'bash',
|
||||
cfg: 'ini',
|
||||
conf: 'ini',
|
||||
css: 'css',
|
||||
dockerfile: 'dockerfile',
|
||||
env: 'env',
|
||||
gql: 'graphql',
|
||||
graphql: 'graphql',
|
||||
ini: 'ini',
|
||||
json: 'json',
|
||||
json5: 'json',
|
||||
less: 'less',
|
||||
markdown: 'markdown',
|
||||
md: 'markdown',
|
||||
mdx: 'markdown',
|
||||
mmd: 'mermaid',
|
||||
ps1: 'powershell',
|
||||
psql: 'sql',
|
||||
sass: 'sass',
|
||||
scss: 'scss',
|
||||
sh: 'bash',
|
||||
sql: 'sql',
|
||||
svg: 'svg',
|
||||
toml: 'toml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yml',
|
||||
zsh: 'zsh'
|
||||
}
|
||||
|
||||
// Pick an icon for a file path by its extension (or bare name like
|
||||
// `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 language = LANGUAGE_BY_EXTENSION[token] || token
|
||||
|
||||
return codiconForLanguage(language)
|
||||
}
|
||||
|
||||
function proseLineCount(body: string): number {
|
||||
return body.split('\n').filter(line => {
|
||||
const trimmed = line.trim()
|
||||
|
|
|
|||
|
|
@ -1214,19 +1214,33 @@ canvas {
|
|||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
/* Fade scaffolding so the prose reading column stays primary. Two targets:
|
||||
a thinking disclosure fades as one block, and each *individual* tool row
|
||||
(`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool
|
||||
group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking
|
||||
context, so a child row can never be more opaque than the group — that made
|
||||
it impossible to keep one row lit (an open diff) while its siblings faded.
|
||||
With the fade per-row, each row hovers/focuses independently. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'],
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] {
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']):is(:hover, :focus-within) {
|
||||
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
|
||||
focus a mouse click leaves on the disclosure toggle, which kept a row lit
|
||||
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)),
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* A generated image is the deliverable, not scaffolding — keep it at full
|
||||
strength instead of dimming it until hover. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:has([data-slot='aui_generated-image']) {
|
||||
/* 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
|
||||
`data-file-edit` marker sits on the same row element and is only present
|
||||
while the row is open. */
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row][data-file-edit] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue