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 (#50731)
* feat(desktop): add Update now button to About panel The About > Updates panel only surfaced "See what's new" when an update was available, which just opens the changelog overlay — there was no way to start the install directly from About. Add an "Update now" primary button that opens the updates overlay (for apply progress) and kicks off the install for the active target (backend in remote mode, else client). * 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. * style(desktop): lead --dt-font-mono with bundled JetBrains Mono Code/diff blocks preferred a system Cascadia Code before the bundled JetBrains Mono, so they drifted from the terminal (which leads with JetBrains Mono) on machines where Cascadia is installed. Reorder so every mono surface uses the face we actually ship. * 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. * style(desktop): use github-dark-dimmed for inline diffs The vivid github-dark-default tokens read harsh behind the add/remove tint in dark mode; switch the diff's dark theme to GitHub's lower-contrast dimmed palette. Light mode and code blocks are unchanged. * style(desktop): dim code-block syntax theme + share with diffs Apply github-dark-dimmed to code blocks too (not just inline diffs) and export one shared SHIKI_THEME so the two highlighters can't drift. Lower contrast reads easier at our small code size in dark mode. * style(desktop): soften shiki token contrast in dark mode github-dark-dimmed only dims the background, which the diff/code surfaces strip — so the bright token foregrounds were unchanged. Pull saturation + brightness back a touch (hues preserved) on .shiki in dark mode for both code blocks and inline diffs.
This commit is contained in:
commit
04a1d9efd7
8 changed files with 634 additions and 80 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} path={isFileEdit ? view.subtitle : undefined} />}
|
||||
{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}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,176 @@
|
|||
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, SHIKI_THEME } 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
|
||||
type DiffKind = 'add' | 'context' | 'remove'
|
||||
|
||||
interface DiffLine {
|
||||
kind: DiffKind
|
||||
text: string
|
||||
}
|
||||
|
||||
const DIFF_LINE_KINDS: DiffLineKind[] = [
|
||||
{
|
||||
className: 'text-emerald-700 dark:text-emerald-300',
|
||||
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: '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'
|
||||
}
|
||||
|
||||
function classifyLine(line: string): string | undefined {
|
||||
return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className
|
||||
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'> {
|
||||
|
|
@ -35,20 +178,28 @@ interface DiffLinesProps extends Omit<React.ComponentProps<'pre'>, 'children'> {
|
|||
}
|
||||
|
||||
export function DiffLines({ className, text, ...props }: DiffLinesProps) {
|
||||
const lines = React.useMemo(() => parseDiff(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',
|
||||
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 || ' '}
|
||||
</span>
|
||||
))}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps {
|
|||
defer?: boolean
|
||||
}
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
// `github-dark-dimmed` is GitHub's lower-contrast dark palette — the vivid
|
||||
// `github-dark-default` tokens read harsh at our small code size. Shared by the
|
||||
// inline diff renderer too (see diff-lines.tsx) so code + diffs match.
|
||||
export const SHIKI_THEME = { dark: 'github-dark-dimmed', light: 'github-light-default' } as const
|
||||
|
||||
/**
|
||||
* `github-light-default` colors comments `#6e7781` (~4.2:1 against the code
|
||||
|
|
|
|||
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,137 @@ 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 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()
|
||||
|
|
|
|||
|
|
@ -299,8 +299,11 @@
|
|||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
|
||||
/* Key caps always use the native UI face — never theme typography overrides. */
|
||||
--dt-font-kbd: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
/* JetBrains Mono first — the face we bundle (@font-face above) and the
|
||||
terminal's primary — so code/diff match the terminal on every platform
|
||||
instead of drifting to a system Cascadia Code where it's installed. */
|
||||
--dt-font-mono:
|
||||
'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji',
|
||||
'JetBrains Mono', 'Cascadia Code', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
|
||||
--dt-base-size: 1rem;
|
||||
--dt-line-height: 1.5;
|
||||
|
|
@ -1214,19 +1217,56 @@ 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']) {
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* The github-dark token palette reads candy-bright at our small code size.
|
||||
`github-dark-dimmed` only dims the *background* (which we strip), so soften
|
||||
the token *foregrounds* directly — a small saturation + brightness pullback,
|
||||
hues preserved — for both code blocks and inline diffs. Dark mode only. */
|
||||
.dark .shiki {
|
||||
filter: saturate(0.82) brightness(0.92);
|
||||
}
|
||||
|
||||
/* 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