Skip to content

Commit b5ef91d

Browse files
committed
feat: add syntax highlighting for diff code using emphasize
Implement language-aware syntax highlighting for diff lines in the TUI. The prefix (+/-/space) retains its diff color while code content gets token-level coloring via the emphasize library (highlight.js wrapper). - Add highlightCode utility: language detection from file extension, ANSI-to-segment parser, and highlightLine function - Modify DiffLine to render highlighted segments for add/delete/context - Support 37 common languages (TypeScript, Python, Go, Rust, etc.) - Pure JS, no native dependencies (CloudShell compatible) - 26 unit tests for highlighting, 14 for DiffLine rendering https://claude.ai/code/session_014NYVEWAyfKd44Ah4QroxBs
1 parent 803b72f commit b5ef91d

6 files changed

Lines changed: 600 additions & 11 deletions

File tree

bun.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"dependencies": {
7575
"@aws-sdk/client-codecommit": "3.990.0",
7676
"@smithy/node-http-handler": "4.4.10",
77+
"emphasize": "^7.0.0",
7778
"ink": "6.7.0",
7879
"ink-text-input": "6.0.0",
7980
"react": "19.2.4"

src/components/DiffLine.test.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { render } from "ink-testing-library";
2+
import React from "react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import type { DisplayLine } from "../utils/formatDiff.js";
5+
import * as highlightCodeModule from "../utils/highlightCode.js";
6+
import { renderDiffLine } from "./DiffLine.js";
7+
8+
function renderLine(line: DisplayLine, isCursor = false): string {
9+
const { lastFrame } = render(<>{renderDiffLine(line, isCursor)}</>);
10+
return lastFrame() ?? "";
11+
}
12+
13+
describe("renderDiffLine with syntax highlighting", () => {
14+
it("highlights add line with known language", () => {
15+
const line: DisplayLine = {
16+
type: "add",
17+
text: "+const x = 42;",
18+
filePath: "src/index.ts",
19+
};
20+
const output = renderLine(line);
21+
expect(output).toContain("+");
22+
expect(output).toContain("const");
23+
expect(output).toContain("42");
24+
});
25+
26+
it("highlights delete line with known language", () => {
27+
const line: DisplayLine = {
28+
type: "delete",
29+
text: "-let y = 0;",
30+
filePath: "src/index.ts",
31+
};
32+
const output = renderLine(line);
33+
expect(output).toContain("-");
34+
expect(output).toContain("let");
35+
});
36+
37+
it("highlights context line with known language", () => {
38+
const line: DisplayLine = {
39+
type: "context",
40+
text: " return x;",
41+
filePath: "src/index.ts",
42+
};
43+
const output = renderLine(line);
44+
expect(output).toContain("return");
45+
});
46+
47+
it("falls back for unknown file extensions", () => {
48+
const line: DisplayLine = {
49+
type: "add",
50+
text: "+some data",
51+
filePath: "data.xyz",
52+
};
53+
const output = renderLine(line);
54+
expect(output).toContain("+some data");
55+
});
56+
57+
it("falls back when no filePath", () => {
58+
const line: DisplayLine = {
59+
type: "add",
60+
text: "+hello",
61+
};
62+
const output = renderLine(line);
63+
expect(output).toContain("+hello");
64+
});
65+
66+
it("applies bold when cursor is active", () => {
67+
const line: DisplayLine = {
68+
type: "add",
69+
text: "+const x = 1;",
70+
filePath: "src/app.ts",
71+
};
72+
const output = renderLine(line, true);
73+
expect(output).toContain("const");
74+
});
75+
76+
it("renders context line without prefixColor", () => {
77+
const line: DisplayLine = {
78+
type: "context",
79+
text: " const y = 2;",
80+
filePath: "src/app.ts",
81+
};
82+
const output = renderLine(line);
83+
expect(output).toContain("const");
84+
expect(output).toContain("2");
85+
});
86+
87+
it("handles empty content after prefix", () => {
88+
const line: DisplayLine = {
89+
type: "add",
90+
text: "+",
91+
filePath: "src/app.ts",
92+
};
93+
const output = renderLine(line);
94+
expect(output).toContain("+");
95+
});
96+
97+
it("highlights Python code", () => {
98+
const line: DisplayLine = {
99+
type: "add",
100+
text: "+def hello():",
101+
filePath: "main.py",
102+
};
103+
const output = renderLine(line);
104+
expect(output).toContain("def");
105+
});
106+
107+
it("highlights JSON", () => {
108+
const line: DisplayLine = {
109+
type: "context",
110+
text: ' "name": "test"',
111+
filePath: "package.json",
112+
};
113+
const output = renderLine(line);
114+
expect(output).toContain("name");
115+
});
116+
117+
it("renders segments with bold and dim attributes", () => {
118+
const line: DisplayLine = {
119+
type: "add",
120+
text: "+import React from 'react';",
121+
filePath: "src/app.tsx",
122+
};
123+
const output = renderLine(line);
124+
expect(output).toContain("import");
125+
expect(output).toContain("React");
126+
});
127+
128+
it("renders non-highlighted line types unchanged", () => {
129+
const header: DisplayLine = { type: "header", text: "src/auth.ts" };
130+
expect(renderLine(header)).toContain("src/auth.ts");
131+
132+
const sep: DisplayLine = { type: "separator", text: "──────" };
133+
expect(renderLine(sep)).toContain("──────");
134+
135+
const trunc: DisplayLine = { type: "truncation", text: "[t] show more" };
136+
expect(renderLine(trunc)).toContain("[t] show more");
137+
});
138+
139+
it("renders segments with bold, dim, and italic attributes", () => {
140+
const spy = vi.spyOn(highlightCodeModule, "highlightLine").mockReturnValueOnce([
141+
{ text: "/* ", dim: true },
142+
{ text: "important", bold: true },
143+
{ text: "comment", italic: true },
144+
{ text: " */", dim: true },
145+
]);
146+
const line: DisplayLine = {
147+
type: "context",
148+
text: " /* comment */",
149+
filePath: "src/index.ts",
150+
};
151+
const output = renderLine(line);
152+
expect(output).toContain("comment");
153+
spy.mockRestore();
154+
});
155+
156+
it("renders single plain segment without wrapping", () => {
157+
const spy = vi
158+
.spyOn(highlightCodeModule, "highlightLine")
159+
.mockReturnValueOnce([{ text: "plain text" }]);
160+
const line: DisplayLine = {
161+
type: "add",
162+
text: "+plain text",
163+
filePath: "src/index.ts",
164+
};
165+
const output = renderLine(line);
166+
expect(output).toContain("plain text");
167+
spy.mockRestore();
168+
});
169+
});

src/components/DiffLine.tsx

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,55 @@
11
import { Text } from "ink";
22
import React from "react";
33
import type { DisplayLine } from "../utils/formatDiff.js";
4+
import { detectLanguage, type HighlightSegment, highlightLine } from "../utils/highlightCode.js";
5+
6+
function renderSegments(segments: HighlightSegment[]): React.ReactNode {
7+
if (segments.length === 1 && !segments[0]?.color && !segments[0]?.bold && !segments[0]?.dim) {
8+
/* v8 ignore next -- segments always have at least one element here */
9+
return segments[0]?.text ?? "";
10+
}
11+
let offset = 0;
12+
return segments.map((seg) => {
13+
const key = `s${offset}`;
14+
offset += seg.text.length;
15+
return (
16+
<Text
17+
key={key}
18+
{...(seg.color ? { color: seg.color } : {})}
19+
{...(seg.bold ? { bold: true } : {})}
20+
{...(seg.dim ? { dimColor: true } : {})}
21+
{...(seg.italic ? { italic: true } : {})}
22+
>
23+
{seg.text}
24+
</Text>
25+
);
26+
});
27+
}
28+
29+
function renderCodeLine(
30+
line: DisplayLine,
31+
prefixColor: string | undefined,
32+
bold: boolean,
33+
): React.ReactNode {
34+
const prefix = line.text.slice(0, 1);
35+
const content = line.text.slice(1);
36+
const lang = line.filePath ? detectLanguage(line.filePath) : undefined;
37+
38+
if (lang && content) {
39+
const segments = highlightLine(content, lang);
40+
return (
41+
<Text bold={bold}>
42+
{prefixColor ? <Text color={prefixColor}>{prefix}</Text> : <Text>{prefix}</Text>}
43+
{renderSegments(segments)}
44+
</Text>
45+
);
46+
}
47+
return (
48+
<Text {...(prefixColor ? { color: prefixColor } : {})} bold={bold}>
49+
{line.text}
50+
</Text>
51+
);
52+
}
453

554
export function renderDiffLine(line: DisplayLine, isCursor = false): React.ReactNode {
655
const bold = isCursor;
@@ -14,19 +63,11 @@ export function renderDiffLine(line: DisplayLine, isCursor = false): React.React
1463
case "separator":
1564
return <Text dimColor>{line.text}</Text>;
1665
case "add":
17-
return (
18-
<Text color="green" bold={bold}>
19-
{line.text}
20-
</Text>
21-
);
66+
return renderCodeLine(line, "green", bold);
2267
case "delete":
23-
return (
24-
<Text color="red" bold={bold}>
25-
{line.text}
26-
</Text>
27-
);
68+
return renderCodeLine(line, "red", bold);
2869
case "context":
29-
return <Text bold={bold}>{line.text}</Text>;
70+
return renderCodeLine(line, undefined, bold);
3071
case "truncate-context":
3172
return <Text dimColor>{line.text}</Text>;
3273
case "truncation":

0 commit comments

Comments
 (0)