, 'children'> {
- text: string
-}
-
-export function DiffLines({ className, text, ...props }: DiffLinesProps) {
- const lines = React.useMemo(() => toDisplayLines(text), [text])
-
- return (
-
- {lines.map((line, index) => (
-
- {line.text || ' '}
-
- ))}
-
- )
-}
-
// 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) => (
+
+ {line.text || ' '}
+
+ ))}
+ >
+ )
+}
+
+// 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) ??
+}
+
+interface DiffLinesProps extends Omit, 'children'> {
+ text: string
+}
+
+export function DiffLines({ className, text, ...props }: DiffLinesProps) {
+ const lines = React.useMemo(() => parseDiff(text), [text])
+
+ return (
+
+
+
+ )
+}
+
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
+ return (
+
+ {canHighlight ? : }
+
+ )
}
diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts
index 6c34b1fcac3..3d9f3e5e1b6 100644
--- a/apps/desktop/src/lib/markdown-code.ts
+++ b/apps/desktop/src/lib/markdown-code.ts
@@ -145,19 +145,100 @@ const LANGUAGE_BY_EXTENSION: Record = {
// `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 = {
+ 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()
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index a56b87186df..4ddc226b305 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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