Skip to content

Commit 354af75

Browse files
committed
Attempt at syntax highlighting with tree sitter
1 parent 98c2f15 commit 354af75

File tree

5 files changed

+540
-20
lines changed

5 files changed

+540
-20
lines changed

cli/src/components/inline-code.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
CodeRenderable,
3+
SyntaxStyle,
4+
parseColor,
5+
type RenderContext,
6+
} from '@opentui/core'
7+
import { extend } from '@opentui/react'
8+
import type { ReactNode } from 'react'
9+
10+
interface InlineCodeProps {
11+
content: string
12+
filetype: string
13+
fg?: string
14+
}
15+
16+
// Create a syntax style for inline code
17+
const inlineCodeSyntaxStyle = SyntaxStyle.fromStyles({
18+
keyword: { fg: parseColor('#FF7B72'), bold: true },
19+
string: { fg: parseColor('#A5D6FF') },
20+
comment: { fg: parseColor('#8B949E'), italic: true },
21+
number: { fg: parseColor('#79C0FF') },
22+
function: { fg: parseColor('#D2A8FF') },
23+
'function.method': { fg: parseColor('#D2A8FF') },
24+
type: { fg: parseColor('#FFA657') },
25+
'type.builtin': { fg: parseColor('#79C0FF') },
26+
operator: { fg: parseColor('#FF7B72') },
27+
variable: { fg: parseColor('#FFA657') },
28+
'variable.parameter': { fg: parseColor('#FFA657') },
29+
property: { fg: parseColor('#79C0FF') },
30+
constant: { fg: parseColor('#79C0FF') },
31+
'constant.builtin': { fg: parseColor('#79C0FF') },
32+
punctuation: { fg: parseColor('#C9D1D9') },
33+
'punctuation.bracket': { fg: parseColor('#C9D1D9') },
34+
'punctuation.delimiter': { fg: parseColor('#C9D1D9') },
35+
default: { fg: parseColor('#F0F6FC') },
36+
})
37+
38+
/**
39+
* Inline code component using OpenTUI's CodeRenderable for syntax highlighting
40+
*/
41+
class InlineCodeRenderable extends CodeRenderable {
42+
constructor(ctx: RenderContext, props: InlineCodeProps) {
43+
super(ctx, {
44+
content: props.content,
45+
filetype: props.filetype,
46+
syntaxStyle: inlineCodeSyntaxStyle,
47+
drawUnstyledText: true,
48+
conceal: false,
49+
})
50+
}
51+
}
52+
53+
// Register with OpenTUI React
54+
declare module '@opentui/react' {
55+
interface OpenTUIComponents {
56+
inlineCode: typeof InlineCodeRenderable
57+
}
58+
}
59+
60+
extend({ inlineCode: InlineCodeRenderable })
61+
62+
/**
63+
* Helper component to render code with optional color overlay
64+
*/
65+
export function InlineCode({
66+
content,
67+
filetype,
68+
fg,
69+
}: InlineCodeProps): ReactNode {
70+
if (!filetype) {
71+
return <span fg={fg}>{content}</span>
72+
}
73+
74+
// Use CodeRenderable for syntax highlighting
75+
return (
76+
<inlineCode content={content} filetype={filetype} fg={fg} />
77+
)
78+
}

cli/src/components/tools/diff-viewer.tsx

Lines changed: 216 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,244 @@
11
import { TextAttributes } from '@opentui/core'
2+
import type { ReactNode } from 'react'
3+
import { useEffect, useState } from 'react'
24

35
import { useTheme } from '../../hooks/use-theme'
6+
import { logger } from '../../utils/logger'
7+
import {
8+
highlightCode,
9+
highlightCodeSync,
10+
} from '../../utils/syntax-highlighter'
411

512
interface DiffViewerProps {
613
diffText: string
14+
filePath: string | undefined
715
}
816

917
const DIFF_LINE_COLORS = {
1018
added: '#B6BD73',
1119
removed: '#BF6C69',
1220
}
1321

14-
const lineColor = (line: string): { fg: string; attrs?: number } => {
22+
/**
23+
* Extract language from file path
24+
*/
25+
const getLanguageFromPath = (filePath: string): string | undefined => {
26+
const ext = filePath.split('.').pop()?.toLowerCase()
27+
if (!ext) return undefined
28+
29+
// Map common extensions to cli-highlight language names
30+
const langMap: Record<string, string> = {
31+
ts: 'typescript',
32+
tsx: 'typescript',
33+
js: 'javascript',
34+
jsx: 'javascript',
35+
py: 'python',
36+
rb: 'ruby',
37+
java: 'java',
38+
c: 'c',
39+
cpp: 'cpp',
40+
cc: 'cpp',
41+
cxx: 'cpp',
42+
h: 'cpp',
43+
hpp: 'cpp',
44+
cs: 'csharp',
45+
go: 'go',
46+
rs: 'rust',
47+
php: 'php',
48+
swift: 'swift',
49+
kt: 'kotlin',
50+
scala: 'scala',
51+
sh: 'bash',
52+
bash: 'bash',
53+
zsh: 'bash',
54+
json: 'json',
55+
xml: 'xml',
56+
html: 'html',
57+
css: 'css',
58+
scss: 'scss',
59+
sass: 'sass',
60+
md: 'markdown',
61+
yaml: 'yaml',
62+
yml: 'yaml',
63+
sql: 'sql',
64+
}
65+
66+
return langMap[ext]
67+
}
68+
69+
/**
70+
* Parse diff to extract file path from diff headers
71+
*/
72+
const parseCurrentFile = (
73+
lines: string[],
74+
currentIndex: number,
75+
): string | undefined => {
76+
// Look backwards from current line to find the most recent diff header
77+
for (let i = currentIndex; i >= 0; i--) {
78+
const line = lines[i]
79+
if (line.startsWith('diff --git')) {
80+
// Extract file path from "diff --git a/path/to/file.ts b/path/to/file.ts"
81+
const match = line.match(/diff --git a\/(.+?) b\/(.+)/)
82+
if (match) {
83+
// Use the 'b' path (new file) as it's more relevant for added/modified files
84+
return match[2]
85+
}
86+
}
87+
if (line.startsWith('+++')) {
88+
// Extract from "+++ b/path/to/file.ts"
89+
const match = line.match(/^\+\+\+ b\/(.+)/)
90+
if (match) {
91+
return match[1]
92+
}
93+
}
94+
}
95+
return undefined
96+
}
97+
98+
interface LineColorResult {
99+
fg: string
100+
attrs?: number
101+
isDiffMarker: boolean
102+
}
103+
104+
const lineColor = (line: string): LineColorResult => {
15105
if (line.startsWith('@@')) {
16-
return { fg: 'cyan', attrs: TextAttributes.BOLD }
106+
return { fg: 'cyan', attrs: TextAttributes.BOLD, isDiffMarker: true }
17107
}
18108
if (line.startsWith('+++') || line.startsWith('---')) {
19-
return { fg: 'gray', attrs: TextAttributes.BOLD }
109+
return { fg: 'gray', attrs: TextAttributes.BOLD, isDiffMarker: true }
20110
}
21111
if (
22112
line.startsWith('diff ') ||
23113
line.startsWith('index ') ||
24114
line.startsWith('rename ') ||
25115
line.startsWith('similarity ')
26116
) {
27-
return { fg: 'gray' }
117+
return { fg: 'gray', isDiffMarker: true }
28118
}
29119
if (line.startsWith('+')) {
30-
return { fg: DIFF_LINE_COLORS.added }
120+
return { fg: DIFF_LINE_COLORS.added, isDiffMarker: false }
31121
}
32122
if (line.startsWith('-')) {
33-
return { fg: DIFF_LINE_COLORS.removed }
123+
return { fg: DIFF_LINE_COLORS.removed, isDiffMarker: false }
34124
}
35125
if (line.startsWith('\\')) {
36-
return { fg: 'gray' }
126+
return { fg: 'gray', isDiffMarker: true }
37127
}
38-
return { fg: '' }
128+
return { fg: '', isDiffMarker: true }
39129
}
40130

41-
export const DiffViewer = ({ diffText }: DiffViewerProps) => {
131+
/**
132+
* Render a diff line with syntax highlighting
133+
*/
134+
const renderDiffLine = (
135+
line: string,
136+
language: string | undefined,
137+
colorInfo: LineColorResult,
138+
theme: { foreground: string },
139+
highlightedCode?: ReactNode,
140+
): ReactNode => {
141+
const { fg, attrs, isDiffMarker } = colorInfo
142+
const resolvedFg = fg || theme.foreground
143+
144+
// For diff markers and context lines, just use plain coloring
145+
if (!language) {
146+
return (
147+
<span fg={resolvedFg} attributes={attrs}>
148+
{line}
149+
</span>
150+
)
151+
}
152+
153+
// For code lines, extract the code content (after the +/- marker)
154+
const prefix = line[0] === '+' ? '+' : line[0] === '-' ? '-' : ''
155+
const codeContent = line.slice(prefix.length) // Remove the +/- prefix
156+
157+
// Use pre-highlighted code if available, otherwise fallback to sync version
158+
const code = highlightedCode ?? highlightCodeSync(codeContent, language, {})
159+
160+
// Wrap the highlighted code with diff color overlay
161+
return (
162+
<span fg={resolvedFg}>
163+
{prefix}
164+
{code}
165+
</span>
166+
)
167+
}
168+
169+
export const DiffViewer = ({ diffText, filePath }: DiffViewerProps) => {
42170
const theme = useTheme()
43171
const lines = diffText.split('\n')
172+
const [highlightedLines, setHighlightedLines] = useState<
173+
Map<number, ReactNode>
174+
>(new Map())
175+
176+
const language = filePath ? getLanguageFromPath(filePath) : undefined
177+
// Async highlight code lines progressively
178+
useEffect(() => {
179+
let ignore = false
180+
const newHighlights = new Map<number, ReactNode>()
181+
182+
const highlightAllLines = async () => {
183+
logger.debug(
184+
{ linesLength: lines.length, lines },
185+
'[DiffViewer] Starting async highlighting',
186+
)
187+
188+
const promises = lines.map(async (rawLine, idx) => {
189+
const line = rawLine.length === 0 ? ' ' : rawLine
190+
191+
logger.debug(
192+
{ idx, line, file: filePath, language },
193+
'[DiffViewer] Line',
194+
)
195+
196+
if (!language) return
197+
198+
// Extract code content (after +/- marker)
199+
const codeContent = line.slice(1)
200+
201+
logger.debug(
202+
{ idx, line, language, content: codeContent.substring(0, 50) },
203+
'[DiffViewer] Highlighting line',
204+
)
205+
206+
try {
207+
const highlighted = await highlightCode(codeContent, language, {})
208+
logger.debug(
209+
{ idx, line, highlighted },
210+
'[DiffViewer] Successfully highlighted line',
211+
)
212+
if (!ignore) {
213+
newHighlights.set(idx, highlighted)
214+
}
215+
} catch (error) {
216+
logger.error(
217+
{ idx, line, error },
218+
'[DiffViewer] Error highlighting line',
219+
)
220+
// Silently fall back to sync highlighting on error
221+
}
222+
})
223+
224+
await Promise.all(promises)
225+
226+
logger.debug(
227+
{ highlights: newHighlights.size },
228+
'[DiffViewer] All highlighting complete',
229+
)
230+
231+
if (!ignore) {
232+
setHighlightedLines(newHighlights)
233+
}
234+
}
235+
236+
highlightAllLines()
237+
238+
return () => {
239+
ignore = true
240+
}
241+
}, [diffText])
44242

45243
return (
46244
<box
@@ -50,13 +248,17 @@ export const DiffViewer = ({ diffText }: DiffViewerProps) => {
50248
.filter((rawLine) => !rawLine.startsWith('@@'))
51249
.map((rawLine, idx) => {
52250
const line = rawLine.length === 0 ? ' ' : rawLine
53-
const { fg, attrs } = lineColor(line)
54-
const resolvedFg = fg || theme.foreground
251+
const colorInfo = lineColor(line)
252+
55253
return (
56254
<text key={`diff-line-${idx}`} style={{ wrapMode: 'none' }}>
57-
<span fg={resolvedFg} attributes={attrs}>
58-
{line}
59-
</span>
255+
{renderDiffLine(
256+
line,
257+
language,
258+
colorInfo,
259+
theme,
260+
highlightedLines.get(idx),
261+
)}
60262
</text>
61263
)
62264
})}

cli/src/components/tools/str-replace.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const EditBody = ({ name, filePath, diffText }: EditBodyProps) => {
7373
<box style={{ flexDirection: 'column', gap: 0, width: '100%' }}>
7474
<EditHeader name={name} filePath={filePath} />
7575
<box style={{ paddingLeft: 2, width: '100%' }}>
76-
<DiffViewer diffText={diffText} />
76+
<DiffViewer diffText={diffText} filePath={filePath ?? undefined} />
7777
</box>
7878
</box>
7979
)

0 commit comments

Comments
 (0)